Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ Changelog
[chris-adam]
- Replaced default actions bar by actionspanel for iconified categories.
[chris-adam]
- Added download QR code page to docx and pdf files to be signed.
[chris-adam]

3.0 (2021-09-30)
----------------
Expand Down
153 changes: 119 additions & 34 deletions imio/dms/mail/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
from collective.dms.scanbehavior.behaviors.behaviors import IScanFields
from collective.documentgenerator import _ as _dg
from collective.documentgenerator.utils import convert_and_save_file
from collective.documentgenerator.utils import convert_odt
from collective.documentgenerator.utils import get_original_template
from collective.documentgenerator.utils import odfsplit
from collective.documentgenerator.utils import update_dict_with_validation
from collective.documentviewer.convert import Converter
from collective.iconifiedcategory.adapter import CategorizedObjectInfoAdapter
from collective.iconifiedcategory.utils import get_category_object
from collective.iconifiedcategory.utils import update_categorized_elements
Expand Down Expand Up @@ -50,6 +52,7 @@
from imio.helpers.content import uuidToCatalogBrain
from imio.helpers.content import uuidToObject
from imio.helpers.emailer import validate_email_address
from imio.helpers.pdf import merge_pdf
from imio.helpers.workflow import do_transitions
from imio.pm.wsclient.interfaces import ISendableAnnexesToPM
from imio.prettylink.adapters import PrettyLinkAdapter
Expand All @@ -61,6 +64,7 @@
from plone.app.contentmenu.menu import FactoriesSubMenuItem as OrigFactoriesSubMenuItem
from plone.app.contentmenu.menu import WorkflowMenu as OrigWorkflowMenu
from plone.app.contenttypes.indexers import _unicode_save_string_concat
from plone.dexterity.utils import createContentInContainer
from plone.indexer import indexer
from plone.namedfile.file import NamedBlobFile
from plone.registry.interfaces import IRegistry
Expand Down Expand Up @@ -1617,8 +1621,11 @@ def approve_file(self, afile, userid, values=None, transition=None, c_a=None):
message=_(
u"The file '${file}' has been approved by ${user}. However, there is/are yet ${nb} files "
u"to approve on this mail.",
mapping={"file": safe_unicode(afile.Title()), "user": safe_unicode(fullname),
"nb": len(yet_to_approve)},
mapping={
"file": safe_unicode(afile.Title()),
"user": safe_unicode(fullname),
"nb": len(yet_to_approve),
},
),
request=request,
type="info",
Expand All @@ -1635,8 +1642,10 @@ def approve_file(self, afile, userid, values=None, transition=None, c_a=None):
self.context.reindexObjectSecurity() # to update local roles from adapter
message += u"Next approval number is ${nb}."
api.portal.show_message(
message=_(message, mapping={"file": safe_unicode(afile.Title()), "user": safe_unicode(fullname),
"nb": c_a + 1}),
message=_(
message,
mapping={"file": safe_unicode(afile.Title()), "user": safe_unicode(fullname), "nb": c_a + 1},
),
request=request,
type="info",
)
Expand Down Expand Up @@ -1725,33 +1734,105 @@ def _create_pdf_file(self, orig_fobj, nbf, f_title, f_uid, file_index, session_f
:param session_file_uids: list to append created pdf file uids
:return: created pdf file object
"""

def render_download_template_to_pdf(uid):
"""Render the download subtemplate (QR code page) to PDF bytes.

:param uid: uid of the document to generate the download link for
:return: PDF bytes, or empty string on failure
"""
try:
download_template = api.portal.get().templates.om.get("download_barcode")
download_url, _short_uid = get_file_download_url(uid, short_uid=get_suid_from_uuid(uid))
helper_view = getMultiAdapter(
(self.context, self.context.REQUEST),
name="document_generation_helper_view",
)
helper_view.pod_template = download_template.UID()
helper_view.output_format = "pdf"
gen_context = {
"context": self.context,
"portal": api.portal.get(),
"view": helper_view,
"download_barcode": generate_barcode(download_url).read(),
"download_url": download_url,
"max_download_date": get_max_download_date(None, adate=datetime.date.today()),
"render_download_barcode": True,
}
template_file = NamedBlobFile(download_template.get_file().data, filename=u"download_template.odt")
return convert_odt(template_file, fmt="pdf", gen_context=gen_context)
except Exception:
logger.exception("Could not render download template to PDF")
Comment on lines +1745 to +1765
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard missing download_barcode template before relying on exception flow.

If the template is absent, this path currently throws and logs a full exception stack on every call. It’s cleaner to short-circuit early and skip URL generation in that case.

💡 Suggested fix
-                download_template = api.portal.get().templates.om.get("download_barcode")
-                download_url, _short_uid = get_file_download_url(uid, short_uid=get_suid_from_uuid(uid))
+                download_template = api.portal.get().templates.om.get("download_barcode")
+                if not download_template:
+                    logger.warning("Template 'download_barcode' is not configured; skipping QR page merge.")
+                    return ""
+                download_url, _short_uid = get_file_download_url(uid, short_uid=get_suid_from_uuid(uid))
🧰 Tools
🪛 Ruff (0.15.2)

[warning] 1764-1764: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@imio/dms/mail/adapters.py` around lines 1745 - 1765, The code currently
assumes download_template exists and relies on the exception handler when it's
missing; update the block starting at download_template =
api.portal.get().templates.om.get("download_barcode") to explicitly guard for a
missing template (e.g., if not download_template) and short-circuit (log a clear
debug/info message and return/skip PDF generation) before calling
get_file_download_url, generate_barcode, NamedBlobFile or convert_odt; keep the
broad try/except for unexpected errors but do not use it to control the normal
missing-template flow.

return ""

def update_esign_attributes(pdf_file, orig_fobj):
"""Set required attributes on a newly created PDF file object."""
pdf_file.to_sign = True
pdf_file.to_approve = False
pdf_file.approved = orig_fobj.approved
update_categorized_elements(
self.context,
pdf_file,
get_category_object(self.context, pdf_file.content_category),
limited=True,
sort=False,
logging=True,
)

new_filename = u"{}.pdf".format(f_title)
if nbf.contentType == "application/pdf":
pdf_file = orig_fobj
new_uid = uuid.uuid4().hex
download_page = render_download_template_to_pdf(new_uid)
if download_page:
merged = merge_pdf(nbf.data, download_page)
file_object = NamedBlobFile(merged, filename=safe_unicode(new_filename))
pdf_file = createContentInContainer(
self.context,
orig_fobj.portal_type,
title=safe_unicode(new_filename),
file=file_object,
content_category=orig_fobj.content_category,
scan_id=orig_fobj.scan_id,
conv_from_uid=f_uid,
**{"_plone.uuid": new_uid}
)
annot = IAnnotations(pdf_file)
annot["documentgenerator"] = {"conv_from_uid": f_uid}
update_esign_attributes(pdf_file, orig_fobj)
elif nbf.contentType in get_allowed_omf_content_types(esign=True):
gen_context = {}
new_uid = uuid.uuid4().hex
download_url, s_uid = get_file_download_url(new_uid, short_uid=get_suid_from_uuid(new_uid))
download_url, _s_uid = get_file_download_url(new_uid, short_uid=get_suid_from_uuid(new_uid))
orig_template = get_original_template(orig_fobj)
doc_cb_download_added = False
if orig_template and nbf.contentType == "application/vnd.oasis.opendocument.text": # own document
helper_view = getMultiAdapter((self.context, self.context.REQUEST),
name='document_generation_helper_view')
helper_view = getMultiAdapter(
(self.context, self.context.REQUEST), name="document_generation_helper_view"
)
helper_view.pod_template = orig_template.UID()
helper_view.output_format = "pdf"
gen_context = {"context": self.context, "portal": api.portal.get(), "view": helper_view}
# update_dict_with_validation(gen_context, self._get_context_variables(pod_template),
# _("Error when merging context_variables in generation context"))
merge_templates = [dic["template"] for dic in orig_template.merge_templates
if dic["pod_context_name"] == "doc_cb_download"]
merge_templates = [
dic["template"]
for dic in orig_template.merge_templates
if dic["pod_context_name"] == "doc_cb_download"
]
if merge_templates:
download_template = uuidToObject(merge_templates[0])
if download_template:
gen_context["doc_cb_download"] = download_template
doc_cb_download_added = True
update_dict_with_validation(
gen_context,
{"download_barcode": generate_barcode(download_url).read(), "download_url": download_url,
"max_download_date": get_max_download_date(None, adate=datetime.date.today()),
"render_download_barcode": True},
{
"download_barcode": generate_barcode(download_url).read(),
"download_url": download_url,
"max_download_date": get_max_download_date(None, adate=datetime.date.today()),
"render_download_barcode": True,
},
_dg("Error when merging 'download_barcode' in generation context"),
)

Expand All @@ -1772,21 +1853,18 @@ def _create_pdf_file(self, orig_fobj, nbf, f_title, f_uid, file_index, session_f
gen_context=gen_context,
)
# we must set attribute after creation
pdf_file.to_sign = True
pdf_file.to_approve = False
pdf_file.approved = orig_fobj.approved
update_categorized_elements(
self.context,
pdf_file,
get_category_object(self.context, pdf_file.content_category),
limited=True,
sort=False,
logging=True,
)
update_esign_attributes(pdf_file, orig_fobj)

# For non-ODT files (e.g. DOC, DOCX), the subtemplate cannot be merged during conversion.
# Render it to PDF separately and append it to the converted PDF.
if not doc_cb_download_added:
download_page = render_download_template_to_pdf(new_uid)
if download_page:
merged = merge_pdf(pdf_file.file.data, download_page)
pdf_file.file = NamedBlobFile(merged, filename=pdf_file.file.filename)
Converter(pdf_file)() # Refresh pdf preview
else:
raise NotImplementedError(
"Cannot convert file of type '{}' to pdf for signing.".format(nbf.contentType)
)
raise NotImplementedError("Cannot convert file of type '{}' to pdf for signing.".format(nbf.contentType))
pdf_uid = pdf_file.UID()
self.pdf_files_uids[file_index].append(pdf_uid)
# we rename the pdf filename to include pdf uid. So after the file is later consumed, we can retrieve object
Expand Down Expand Up @@ -1856,21 +1934,28 @@ def add_mail_files_to_session(self):
signers.append((signer, email, name, label))
watcher_users = api.user.get_users(groupname="esign_watchers")
watcher_emails = [user.getProperty("email") for user in watcher_users]
session_id, session = add_files_to_session(signers, session_file_uids, bool(self.context.seal),
title=_("[ia.docs] Session {sign_id}"),
watchers=watcher_emails)
session_id, _session = add_files_to_session(
signers,
session_file_uids,
bool(self.context.seal),
title=_("[ia.docs] Session {sign_id}"),
watchers=watcher_emails,
)
self.annot["session_id"] = session_id
session_len = len(session_file_uids)
if session_len > 1:
return True, _("${count} files added to session number ${session_id}",
mapping={"count": session_len, "session_id": session_id})
return True, _(
"${count} files added to session number ${session_id}",
mapping={"count": session_len, "session_id": session_id},
)
else:
return True, _("${count} file added to session number ${session_id}",
mapping={"count": session_len, "session_id": session_id})
return True, _(
"${count} file added to session number ${session_id}",
mapping={"count": session_len, "session_id": session_id},
)


class DmsCategorizedObjectInfoAdapter(CategorizedObjectInfoAdapter):

def get_infos(self, category, limited=False):
base_infos = super(DmsCategorizedObjectInfoAdapter, self).get_infos(category, limited=limited)
base_infos["scan_id"] = getattr(self.obj, "scan_id", None)
Expand Down
132 changes: 132 additions & 0 deletions imio/dms/mail/tests/test_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,18 @@
from imio.esign.config import set_registry_file_url
from imio.esign.utils import get_session_annotation
from imio.helpers.test_helpers import ImioTestHelpers
from imio.helpers.tests.test_pdf import _pdf_page_count
from io import BytesIO
from mock import Mock
from mock import patch
from plone import api
from plone.dexterity.utils import createContentInContainer
from plone.namedfile.file import NamedBlobFile
from plone.registry.interfaces import IRegistry
from Products.CMFCore.utils import getToolByName
from reportlab.pdfgen import canvas as pdf_canvas
from z3c.relationfield.relation import RelationValue
from zope.annotation.interfaces import IAnnotations
from zope.component import getUtility
from zope.intid.interfaces import IIntIds
from zope.lifecycleevent import Attributes
Expand Down Expand Up @@ -1506,3 +1513,128 @@ def test_add_mail_files_to_session(self):
"c_uids": {self.omail.UID(): [pdf_file.UID()]},
},
)

def test_create_pdf_file_from_pdf(self):
"""Through the esignature process, a PDF file gets a QR barcode page appended."""

# Replace existing ODT files with a PDF file in the approval process
self.approval.remove_file_from_approval(self.files[0].UID())
self.approval.remove_file_from_approval(self.files[1].UID())
ct = self.portal["annexes_types"]["outgoing_dms_files"]["outgoing-dms-file"]
with open("%s/tests/files/example.pdf" % PRODUCT_DIR, "rb") as fo:
pdf_data = fo.read()
pdf_fobj = createContentInContainer(
self.omail,
"dmsommainfile",
id="pdffile",
scan_id="012999900000601",
file=NamedBlobFile(pdf_data, filename=u"example.pdf"),
content_category=calculate_category_id(ct),
)

self.pw.doActionFor(self.omail, "propose_to_approve")
self.approval.approve_file(pdf_fobj, "dirg")
self.approval.approve_file(pdf_fobj, "bourgmestre")

# 2 ODT originals + 1 PDF original + 1 merged PDF
self.assertEqual(len(list(self.omail.objectIds())), 4)

# Locate the created PDF via the approval annotation
new_uid = self.approval.annot["pdf_files"][0][0]
self.assertNotEqual(new_uid, pdf_fobj.UID()) # PDF original != merged PDF
catalog = getToolByName(self.portal, "portal_catalog")
brains = catalog(UID=new_uid)
self.assertEqual(len(brains), 1)
new_fobj = brains[0].getObject()

# Check page count: 6 pages (example.pdf) + 1 barcode page = 7
page_count = _pdf_page_count(new_fobj.file.data)
self.assertEqual(page_count, 7)

# Check all required attributes
self.assertTrue(new_fobj.to_sign)
self.assertFalse(new_fobj.signed)
self.assertFalse(new_fobj.to_approve)
self.assertTrue(new_fobj.approved)
self.assertEqual(new_fobj.content_category, "plone-annexes_types_-_outgoing_dms_files_-_outgoing-dms-file")
self.assertEqual(new_fobj.conv_from_uid, pdf_fobj.UID())

def test_create_pdf_file_from_odt(self):
"""Through the esignature process, a real ODT file is embedded
with the download code bar template then converted to PDF."""

# Keep self.files[0] (ODT) in approval; remove file1
self.approval.remove_file_from_approval(self.files[1].UID())

self.pw.doActionFor(self.omail, "propose_to_approve")
self.approval.approve_file(self.files[0], "dirg")
self.approval.approve_file(self.files[0], "bourgmestre")

# 2 ODT originals + 1 converted PDF
self.assertEqual(len(list(self.omail.objectIds())), 3)
self.assertIn("reponse-salle.pdf", self.omail)
pdf_file = self.omail["reponse-salle.pdf"]

# Check page count: at least one page produced by the conversion
page_count = _pdf_page_count(pdf_file.file.data)
self.assertGreaterEqual(page_count, 1)

# Check all required attributes
self.assertTrue(pdf_file.to_sign)
self.assertFalse(pdf_file.signed)
self.assertFalse(pdf_file.to_approve)
self.assertTrue(pdf_file.approved)
self.assertEqual(pdf_file.content_category, "plone-annexes_types_-_outgoing_dms_files_-_outgoing-dms-file")
self.assertEqual(pdf_file.conv_from_uid, self.files[0].UID())

def test_create_pdf_file_from_doc(self):
"""Through the esignature process, a DOC file is converted to PDF
with a QR barcode page appended."""

api.portal.set_registry_record(
"imio.dms.mail.browser.settings.IImioDmsMailConfig.omail_esign_formats",
["odt", "pdf", "doc"],
)

try:
# Remove existing ODT files from approval, then add a DOC file
self.approval.remove_file_from_approval(self.files[0].UID())
self.approval.remove_file_from_approval(self.files[1].UID())
ct = self.portal["annexes_types"]["outgoing_dms_files"]["outgoing-dms-file"]
with open("%s/batchimport/toprocess/incoming-mail/in-courrier4.doc" % PRODUCT_DIR, "rb") as fo:
doc_data = fo.read()
doc_fobj = createContentInContainer(
self.omail,
"dmsommainfile",
id="docfile",
scan_id="012999900000601",
file=NamedBlobFile(doc_data, contentType="application/msword", filename=u"in-courrier4.doc"),
content_category=calculate_category_id(ct),
)

self.pw.doActionFor(self.omail, "propose_to_approve")
# Signer 0 (dirg) approves — no session creation yet
self.approval.approve_file(doc_fobj, "dirg")
# Signer 1 (bourgmestre) approves — triggers add_mail_files_to_session()
self.approval.approve_file(doc_fobj, "bourgmestre")
finally:
api.portal.set_registry_record(
"imio.dms.mail.browser.settings.IImioDmsMailConfig.omail_esign_formats",
["odt", "pdf"],
)
Comment on lines +1594 to +1624
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Restore the previous registry value instead of a hard-coded fallback.

Lines 1629–1632 force ["odt", "pdf"] regardless of prior state, which can leak config changes across tests if defaults evolve.

💡 Proposed fix
-        api.portal.set_registry_record(
+        previous_formats = api.portal.get_registry_record(
+            "imio.dms.mail.browser.settings.IImioDmsMailConfig.omail_esign_formats"
+        )
+        api.portal.set_registry_record(
             "imio.dms.mail.browser.settings.IImioDmsMailConfig.omail_esign_formats",
             ["odt", "pdf", "doc"],
         )
@@
         finally:
             api.portal.set_registry_record(
                 "imio.dms.mail.browser.settings.IImioDmsMailConfig.omail_esign_formats",
-                ["odt", "pdf"],
+                previous_formats,
             )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@imio/dms/mail/tests/test_adapters.py` around lines 1602 - 1632, Before
overwriting the registry key
"imio.dms.mail.browser.settings.IImioDmsMailConfig.omail_esign_formats", capture
its current value into a variable (e.g., previous_omail_esign_formats =
api.portal.get_registry_record(...)), then set the temporary value as you do; in
the finally block restore the original value by calling
api.portal.set_registry_record with that saved variable instead of the
hard-coded ["odt", "pdf"]; reference the test code around the calls to
api.portal.set_registry_record and the finally block that currently forces the
fallback.


# 2 ODT originals + 1 DOC + 1 converted PDF
self.assertEqual(len(list(self.omail.objectIds())), 4)

# At least 1 converted content page + 1 barcode page
pdf_file = self.omail.values()[-1]
page_count = _pdf_page_count(pdf_file.file.data)
self.assertGreaterEqual(page_count, 2)

# Check all required attributes
self.assertTrue(pdf_file.to_sign)
self.assertFalse(pdf_file.signed)
self.assertFalse(pdf_file.to_approve)
self.assertTrue(pdf_file.approved)
self.assertEqual(pdf_file.content_category, "plone-annexes_types_-_outgoing_dms_files_-_outgoing-dms-file")
self.assertEqual(pdf_file.conv_from_uid, doc_fobj.UID())
Loading