Skip to content

Commit 72801a8

Browse files
committed
Added download QR code page to docx and pdf files to be signed
1 parent f7c5959 commit 72801a8

File tree

4 files changed

+254
-23
lines changed

4 files changed

+254
-23
lines changed

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ Changelog
4848
[chris-adam]
4949
- Replaced default actions bar by actionspanel for iconified categories.
5050
[chris-adam]
51+
- Added download QR code page to docx and pdf files to be signed.
52+
[chris-adam]
5153

5254
3.0 (2021-09-30)
5355
----------------

imio/dms/mail/adapters.py

Lines changed: 112 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616
from collective.dms.scanbehavior.behaviors.behaviors import IScanFields
1717
from collective.documentgenerator import _ as _dg
1818
from collective.documentgenerator.utils import convert_and_save_file
19+
from collective.documentgenerator.utils import convert_odt
1920
from collective.documentgenerator.utils import get_original_template
2021
from collective.documentgenerator.utils import odfsplit
2122
from collective.documentgenerator.utils import update_dict_with_validation
23+
from collective.documentviewer.convert import Converter
2224
from collective.iconifiedcategory.adapter import CategorizedObjectInfoAdapter
2325
from collective.iconifiedcategory.utils import get_category_object
2426
from collective.iconifiedcategory.utils import update_categorized_elements
@@ -50,6 +52,7 @@
5052
from imio.helpers.content import uuidToCatalogBrain
5153
from imio.helpers.content import uuidToObject
5254
from imio.helpers.emailer import validate_email_address
55+
from imio.helpers.pdf import merge_pdf
5356
from imio.helpers.workflow import do_transitions
5457
from imio.pm.wsclient.interfaces import ISendableAnnexesToPM
5558
from imio.prettylink.adapters import PrettyLinkAdapter
@@ -61,6 +64,7 @@
6164
from plone.app.contentmenu.menu import FactoriesSubMenuItem as OrigFactoriesSubMenuItem
6265
from plone.app.contentmenu.menu import WorkflowMenu as OrigWorkflowMenu
6366
from plone.app.contenttypes.indexers import _unicode_save_string_concat
67+
from plone.dexterity.utils import createContentInContainer
6468
from plone.indexer import indexer
6569
from plone.namedfile.file import NamedBlobFile
6670
from plone.registry.interfaces import IRegistry
@@ -1617,8 +1621,11 @@ def approve_file(self, afile, userid, values=None, transition=None, c_a=None):
16171621
message=_(
16181622
u"The file '${file}' has been approved by ${user}. However, there is/are yet ${nb} files "
16191623
u"to approve on this mail.",
1620-
mapping={"file": safe_unicode(afile.Title()), "user": safe_unicode(fullname),
1621-
"nb": len(yet_to_approve)},
1624+
mapping={
1625+
"file": safe_unicode(afile.Title()),
1626+
"user": safe_unicode(fullname),
1627+
"nb": len(yet_to_approve),
1628+
},
16221629
),
16231630
request=request,
16241631
type="info",
@@ -1635,8 +1642,10 @@ def approve_file(self, afile, userid, values=None, transition=None, c_a=None):
16351642
self.context.reindexObjectSecurity() # to update local roles from adapter
16361643
message += u"Next approval number is ${nb}."
16371644
api.portal.show_message(
1638-
message=_(message, mapping={"file": safe_unicode(afile.Title()), "user": safe_unicode(fullname),
1639-
"nb": c_a + 1}),
1645+
message=_(
1646+
message,
1647+
mapping={"file": safe_unicode(afile.Title()), "user": safe_unicode(fullname), "nb": c_a + 1},
1648+
),
16401649
request=request,
16411650
type="info",
16421651
)
@@ -1714,6 +1723,31 @@ def unapprove_file(self, afile, signer_userid):
17141723

17151724
self.start_approval_process()
17161725

1726+
def _render_download_template_to_pdf(self, download_template, download_url):
1727+
"""Render the download subtemplate (QR code page) to PDF bytes.
1728+
1729+
:param download_template: the POD template object to render
1730+
:param download_url: the URL to encode in the QR/barcode
1731+
:return: PDF bytes, or empty string on failure
1732+
"""
1733+
helper_view = getMultiAdapter(
1734+
(self.context, self.context.REQUEST),
1735+
name="document_generation_helper_view",
1736+
)
1737+
helper_view.pod_template = download_template.UID()
1738+
helper_view.output_format = "pdf"
1739+
gen_context = {
1740+
"context": self.context,
1741+
"portal": api.portal.get(),
1742+
"view": helper_view,
1743+
"download_barcode": generate_barcode(download_url).read(),
1744+
"download_url": download_url,
1745+
"max_download_date": get_max_download_date(None, adate=datetime.date.today()),
1746+
"render_download_barcode": True,
1747+
}
1748+
template_file = NamedBlobFile(download_template.get_file().data, filename=u"download_template.odt")
1749+
return convert_odt(template_file, fmt="pdf", gen_context=gen_context)
1750+
17171751
def _create_pdf_file(self, orig_fobj, nbf, f_title, f_uid, file_index, session_file_uids):
17181752
"""Create a pdf version file.
17191753
@@ -1728,32 +1762,72 @@ def _create_pdf_file(self, orig_fobj, nbf, f_title, f_uid, file_index, session_f
17281762
new_filename = u"{}.pdf".format(f_title)
17291763
if nbf.contentType == "application/pdf":
17301764
pdf_file = orig_fobj
1765+
download_template = api.portal.get().templates.om.get("download_barcode")
1766+
if download_template:
1767+
new_uid = uuid.uuid4().hex
1768+
dl_url, _ = get_file_download_url(new_uid, short_uid=get_suid_from_uuid(new_uid))
1769+
sub_pdf_data = self._render_download_template_to_pdf(download_template, dl_url)
1770+
if sub_pdf_data:
1771+
merged = merge_pdf(nbf.data, sub_pdf_data)
1772+
file_object = NamedBlobFile(merged, filename=safe_unicode(new_filename))
1773+
pdf_file = createContentInContainer(
1774+
self.context,
1775+
orig_fobj.portal_type,
1776+
title=safe_unicode(new_filename),
1777+
file=file_object,
1778+
content_category=orig_fobj.content_category,
1779+
scan_id=orig_fobj.scan_id,
1780+
conv_from_uid=f_uid,
1781+
**{"_plone.uuid": new_uid}
1782+
)
1783+
annot = IAnnotations(pdf_file)
1784+
annot["documentgenerator"] = {"conv_from_uid": f_uid}
1785+
pdf_file.to_sign = True
1786+
pdf_file.to_approve = False
1787+
pdf_file.approved = orig_fobj.approved
1788+
update_categorized_elements(
1789+
self.context,
1790+
pdf_file,
1791+
get_category_object(self.context, pdf_file.content_category),
1792+
limited=True,
1793+
sort=False,
1794+
logging=True,
1795+
)
17311796
elif nbf.contentType in get_allowed_omf_content_types(esign=True):
17321797
gen_context = {}
17331798
new_uid = uuid.uuid4().hex
17341799
download_url, s_uid = get_file_download_url(new_uid, short_uid=get_suid_from_uuid(new_uid))
17351800
orig_template = get_original_template(orig_fobj)
1801+
doc_cb_download_added = False
17361802
if orig_template and nbf.contentType == "application/vnd.oasis.opendocument.text": # own document
1737-
helper_view = getMultiAdapter((self.context, self.context.REQUEST),
1738-
name='document_generation_helper_view')
1803+
helper_view = getMultiAdapter(
1804+
(self.context, self.context.REQUEST), name="document_generation_helper_view"
1805+
)
17391806
helper_view.pod_template = orig_template.UID()
17401807
helper_view.output_format = "pdf"
17411808
gen_context = {"context": self.context, "portal": api.portal.get(), "view": helper_view}
17421809
# update_dict_with_validation(gen_context, self._get_context_variables(pod_template),
17431810
# _("Error when merging context_variables in generation context"))
1744-
merge_templates = [dic["template"] for dic in orig_template.merge_templates
1745-
if dic["pod_context_name"] == "doc_cb_download"]
1811+
merge_templates = [
1812+
dic["template"]
1813+
for dic in orig_template.merge_templates
1814+
if dic["pod_context_name"] == "doc_cb_download"
1815+
]
17461816
if merge_templates:
17471817
download_template = uuidToObject(merge_templates[0])
17481818
if download_template:
17491819
gen_context["doc_cb_download"] = download_template
17501820
update_dict_with_validation(
17511821
gen_context,
1752-
{"download_barcode": generate_barcode(download_url).read(), "download_url": download_url,
1753-
"max_download_date": get_max_download_date(None, adate=datetime.date.today()),
1754-
"render_download_barcode": True},
1822+
{
1823+
"download_barcode": generate_barcode(download_url).read(),
1824+
"download_url": download_url,
1825+
"max_download_date": get_max_download_date(None, adate=datetime.date.today()),
1826+
"render_download_barcode": True,
1827+
},
17551828
_dg("Error when merging 'download_barcode' in generation context"),
17561829
)
1830+
doc_cb_download_added = True
17571831

17581832
# TODO which pdf format to choose ?
17591833
pdf_file = convert_and_save_file(
@@ -1783,10 +1857,19 @@ def _create_pdf_file(self, orig_fobj, nbf, f_title, f_uid, file_index, session_f
17831857
sort=False,
17841858
logging=True,
17851859
)
1860+
1861+
# For non-ODT files (e.g. DOC, DOCX), the subtemplate cannot be merged during conversion.
1862+
# Render it to PDF separately and append it to the converted PDF.
1863+
if not doc_cb_download_added:
1864+
download_template = api.portal.get().templates.om.get("download_barcode")
1865+
if download_template:
1866+
sub_pdf_data = self._render_download_template_to_pdf(download_template, download_url)
1867+
if sub_pdf_data:
1868+
merged = merge_pdf(pdf_file.file.data, sub_pdf_data)
1869+
pdf_file.file = NamedBlobFile(merged, filename=pdf_file.file.filename)
1870+
Converter(pdf_file)() # Refresh pdf preview
17861871
else:
1787-
raise NotImplementedError(
1788-
"Cannot convert file of type '{}' to pdf for signing.".format(nbf.contentType)
1789-
)
1872+
raise NotImplementedError("Cannot convert file of type '{}' to pdf for signing.".format(nbf.contentType))
17901873
pdf_uid = pdf_file.UID()
17911874
self.pdf_files_uids[file_index].append(pdf_uid)
17921875
# we rename the pdf filename to include pdf uid. So after the file is later consumed, we can retrieve object
@@ -1856,21 +1939,28 @@ def add_mail_files_to_session(self):
18561939
signers.append((signer, email, name, label))
18571940
watcher_users = api.user.get_users(groupname="esign_watchers")
18581941
watcher_emails = [user.getProperty("email") for user in watcher_users]
1859-
session_id, session = add_files_to_session(signers, session_file_uids, bool(self.context.seal),
1860-
title=_("[ia.docs] Session {sign_id}"),
1861-
watchers=watcher_emails)
1942+
session_id, session = add_files_to_session(
1943+
signers,
1944+
session_file_uids,
1945+
bool(self.context.seal),
1946+
title=_("[ia.docs] Session {sign_id}"),
1947+
watchers=watcher_emails,
1948+
)
18621949
self.annot["session_id"] = session_id
18631950
session_len = len(session_file_uids)
18641951
if session_len > 1:
1865-
return True, _("${count} files added to session number ${session_id}",
1866-
mapping={"count": session_len, "session_id": session_id})
1952+
return True, _(
1953+
"${count} files added to session number ${session_id}",
1954+
mapping={"count": session_len, "session_id": session_id},
1955+
)
18671956
else:
1868-
return True, _("${count} file added to session number ${session_id}",
1869-
mapping={"count": session_len, "session_id": session_id})
1957+
return True, _(
1958+
"${count} file added to session number ${session_id}",
1959+
mapping={"count": session_len, "session_id": session_id},
1960+
)
18701961

18711962

18721963
class DmsCategorizedObjectInfoAdapter(CategorizedObjectInfoAdapter):
1873-
18741964
def get_infos(self, category, limited=False):
18751965
base_infos = super(DmsCategorizedObjectInfoAdapter, self).get_infos(category, limited=limited)
18761966
base_infos["scan_id"] = getattr(self.obj, "scan_id", None)

imio/dms/mail/tests/test_adapters.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,13 @@
3434
from imio.esign.config import set_registry_file_url
3535
from imio.esign.utils import get_session_annotation
3636
from imio.helpers.test_helpers import ImioTestHelpers
37+
from mock import Mock
38+
from mock import patch
3739
from plone import api
3840
from plone.dexterity.utils import createContentInContainer
3941
from plone.namedfile.file import NamedBlobFile
4042
from plone.registry.interfaces import IRegistry
43+
from Products.CMFCore.utils import getToolByName
4144
from z3c.relationfield.relation import RelationValue
4245
from zope.component import getUtility
4346
from zope.intid.interfaces import IIntIds
@@ -1506,3 +1509,139 @@ def test_add_mail_files_to_session(self):
15061509
"c_uids": {self.omail.UID(): [pdf_file.UID()]},
15071510
},
15081511
)
1512+
1513+
def test_render_download_template_to_pdf(self):
1514+
"""_render_download_template_to_pdf builds the gen_context and delegates to convert_odt."""
1515+
fake_pdf_bytes = b"%PDF-1.4 fake"
1516+
mock_template = Mock()
1517+
mock_template.UID.return_value = "template-uid"
1518+
mock_template.get_file.return_value = Mock(data=b"odt content")
1519+
1520+
with patch("imio.dms.mail.adapters.convert_odt", return_value=fake_pdf_bytes) as mock_conv:
1521+
with patch("imio.dms.mail.adapters.generate_barcode") as mock_barcode:
1522+
mock_barcode.return_value = Mock(**{"read.return_value": b"barcode-bytes"})
1523+
with patch("imio.dms.mail.adapters.getMultiAdapter") as mock_adapt:
1524+
mock_adapt.return_value = Mock()
1525+
result = self.approval._render_download_template_to_pdf(
1526+
mock_template, "https://downloads.files.com/abc"
1527+
)
1528+
1529+
self.assertEqual(result, fake_pdf_bytes)
1530+
call_args = mock_conv.call_args
1531+
self.assertEqual(call_args[1]["fmt"], "pdf")
1532+
gen_context = call_args[1]["gen_context"]
1533+
self.assertEqual(gen_context["context"], self.omail)
1534+
self.assertEqual(gen_context["download_barcode"], b"barcode-bytes")
1535+
self.assertEqual(gen_context["download_url"], "https://downloads.files.com/abc")
1536+
self.assertTrue(gen_context["render_download_barcode"])
1537+
1538+
def test_create_pdf_file_from_pdf(self):
1539+
"""TODO"""
1540+
1541+
def test_create_pdf_file_from_odt(self):
1542+
"""When input is a PDF and download_barcode template exists, a new file with the QR page is created."""
1543+
fake_sub_pdf = b"%PDF-1.4 sub"
1544+
fake_merged = b"%PDF-1.4 merged"
1545+
1546+
ct = self.portal["annexes_types"]["outgoing_dms_files"]["outgoing-dms-file"]
1547+
with open("%s/tests/files/example.pdf" % PRODUCT_DIR, "rb") as fo:
1548+
pdf_data = fo.read()
1549+
pdf_blob = NamedBlobFile(pdf_data, filename=u"test.pdf")
1550+
pdf_fobj = createContentInContainer(
1551+
self.omail,
1552+
"dmsommainfile",
1553+
id="pdffile2",
1554+
scan_id="012999900000601",
1555+
file=pdf_blob,
1556+
content_category=calculate_category_id(ct),
1557+
)
1558+
1559+
mock_template = Mock()
1560+
mock_template.UID.return_value = "dl-barcode-uid"
1561+
mock_template.get_file.return_value = Mock(data=b"odt content")
1562+
1563+
session_file_uids = []
1564+
with patch.object(self.portal.templates.om, "get", return_value=mock_template):
1565+
with patch.object(self.approval, "_render_download_template_to_pdf", return_value=fake_sub_pdf):
1566+
with patch("imio.dms.mail.adapters.merge_pdf", return_value=fake_merged):
1567+
self.approval._create_pdf_file(
1568+
pdf_fobj, pdf_fobj.file, u"test", pdf_fobj.UID(), 0, session_file_uids
1569+
)
1570+
1571+
self.assertEqual(len(self.omail.objectIds()), 4)
1572+
self.assertEqual(len(session_file_uids), 1)
1573+
new_uid = session_file_uids[0]
1574+
self.assertNotEqual(new_uid, pdf_fobj.UID())
1575+
catalog = getToolByName(self.portal, "portal_catalog")
1576+
brains = catalog(UID=new_uid)
1577+
self.assertEqual(len(brains), 1)
1578+
new_fobj = brains[0].getObject()
1579+
self.assertTrue(new_fobj.to_sign)
1580+
self.assertFalse(new_fobj.to_approve)
1581+
1582+
def test_create_pdf_file_from_docx(self):
1583+
"""For non-ODT files (DOCX), the QR page is appended after conversion."""
1584+
fake_sub_pdf = b"%PDF-1.4 sub"
1585+
fake_merged = b"%PDF-1.4 merged"
1586+
1587+
api.portal.set_registry_record(
1588+
"imio.dms.mail.browser.settings.IImioDmsMailConfig.omail_esign_formats",
1589+
["odt", "pdf", "doc"],
1590+
)
1591+
1592+
ct = self.portal["annexes_types"]["outgoing_dms_files"]["outgoing-dms-file"]
1593+
docx_blob = NamedBlobFile(
1594+
b"PK fake docx content",
1595+
contentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
1596+
filename=u"test.docx",
1597+
)
1598+
docx_fobj = createContentInContainer(
1599+
self.omail,
1600+
"dmsommainfile",
1601+
id="docxfile",
1602+
scan_id="012999900000601",
1603+
file=docx_blob,
1604+
content_category=calculate_category_id(ct),
1605+
)
1606+
1607+
mock_template = Mock()
1608+
mock_template.UID.return_value = "dl-barcode-uid"
1609+
mock_template.get_file.return_value = Mock(data=b"odt content")
1610+
1611+
mock_pdf_file = Mock()
1612+
mock_pdf_file.UID.return_value = "new-pdf-uid"
1613+
mock_pdf_file.file = Mock()
1614+
mock_pdf_file.file.data = b"%PDF-1.4 converted"
1615+
mock_pdf_file.file.filename = u"test.pdf"
1616+
1617+
session_file_uids = []
1618+
try:
1619+
with patch("imio.dms.mail.adapters.convert_and_save_file", return_value=mock_pdf_file):
1620+
with patch("imio.dms.mail.adapters.get_original_template", return_value=None):
1621+
with patch("imio.dms.mail.adapters.update_categorized_elements"):
1622+
with patch("imio.dms.mail.adapters.get_category_object", return_value=Mock()):
1623+
with patch.object(self.portal.templates.om, "get", return_value=mock_template):
1624+
with patch.object(
1625+
self.approval, "_render_download_template_to_pdf", return_value=fake_sub_pdf
1626+
):
1627+
with patch("imio.dms.mail.adapters.merge_pdf", return_value=fake_merged):
1628+
with patch("imio.dms.mail.adapters.Converter") as mock_converter_cls:
1629+
mock_converter_cls.return_value = Mock()
1630+
self.approval._create_pdf_file(
1631+
docx_fobj,
1632+
docx_fobj.file,
1633+
u"test",
1634+
docx_fobj.UID(),
1635+
0,
1636+
session_file_uids,
1637+
)
1638+
finally:
1639+
api.portal.set_registry_record(
1640+
"imio.dms.mail.browser.settings.IImioDmsMailConfig.omail_esign_formats",
1641+
["odt", "pdf"],
1642+
)
1643+
1644+
self.assertEqual(mock_pdf_file.file.data, fake_merged)
1645+
mock_converter_cls.assert_called_once_with(mock_pdf_file)
1646+
mock_converter_cls.return_value.assert_called_once()
1647+
self.assertEqual(session_file_uids, ["new-pdf-uid"])

0 commit comments

Comments
 (0)