Skip to content

Commit de5ca03

Browse files
committed
Added SessionAnnotationInfoView WIP
1 parent 94c7b5e commit de5ca03

File tree

5 files changed

+303
-0
lines changed

5 files changed

+303
-0
lines changed

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ Changelog
2929
[chris-adam]
3030
- Added external watchers for esign sessions.
3131
[chris-adam, sgeulette]
32+
- Added SessionAnnotationInfoView.
33+
[chris-adam]
3234

3335
1.0a2 (2026-02-06)
3436
------------------

src/imio/esign/browser/actions.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
# -*- coding: utf-8 -*-
2+
from AccessControl import Unauthorized
3+
from html import escape
4+
from imio.dms.mail.utils import persistent_to_native
25
from imio.esign import _
36
from imio.esign.adapters import ISignable
47
from imio.esign.utils import add_files_to_session
58
from imio.esign.utils import get_session_annotation
69
from imio.esign.utils import remove_context_from_session
710
from imio.esign.utils import remove_files_from_session
11+
from imio.helpers.content import uuidToObject
12+
from imio.helpers.security import check_zope_admin
813
from plone import api
14+
from Products.CMFPlone.utils import safe_unicode
915
from Products.Five import BrowserView
1016

17+
import pprint
18+
import re
19+
1120

1221
class AddToSessionView(BrowserView):
1322
"""View to add an element to an esign session."""
@@ -121,3 +130,65 @@ def available(self):
121130
"""Defines if the action is available or not."""
122131
annot = get_session_annotation()
123132
return self.context.UID() in annot.get("uids", {})
133+
134+
135+
class SessionAnnotationInfoView(BrowserView):
136+
"""Admin-only view displaying imio.esign session annotations for a specific context item."""
137+
138+
def __call__(self):
139+
if not check_zope_admin():
140+
raise Unauthorized
141+
return self.index()
142+
143+
def _uid_to_link(self, uid):
144+
"""Return an HTML link for an object UID, or the UID if not found."""
145+
obj = uuidToObject(uid, unrestricted=True)
146+
if obj is None:
147+
return u"<span title='not found'>{}</span>".format(safe_unicode(uid))
148+
url = escape(obj.absolute_url(), quote=True)
149+
path = escape(u"/".join(obj.getPhysicalPath()))
150+
title = escape(safe_unicode(getattr(obj, "title", "") or path))
151+
return u"<a href='{}' title='{}'>{}</a>".format(url, path, title)
152+
153+
def _render_value(self, value, indent=u""):
154+
"""Render a value, replacing UIDs with links where possible."""
155+
inner = indent + u" "
156+
if isinstance(value, dict):
157+
if not value:
158+
return u"{}"
159+
lines = [u"{"]
160+
for k, v in sorted(value.items()):
161+
key = escape(safe_unicode(pprint.pformat(k)))
162+
lines.append(u"{}{}: {},".format(inner, key, self._render_value(v, inner)))
163+
lines.append(u"{}}}".format(indent))
164+
return u"\n".join(lines)
165+
elif isinstance(value, (list, tuple)):
166+
if not value:
167+
return u"[]"
168+
lines = [u"["]
169+
for item in value:
170+
lines.append(u"{}{},".format(inner, self._render_value(item, inner)))
171+
lines.append(u"{}]".format(indent))
172+
return u"\n".join(lines)
173+
elif isinstance(value, basestring) and re.match(r"^[0-9a-f]{32}$", value):
174+
# Looks like a UUID
175+
return self._uid_to_link(value)
176+
else:
177+
return escape(safe_unicode(pprint.pformat(value)))
178+
179+
@property
180+
def esign_sessions(self):
181+
"""Return list of (session_id, session_data) for all sessions."""
182+
annot = get_session_annotation()
183+
c_uid = self.context.UID()
184+
result = []
185+
for session_id in annot['sessions']:
186+
session = annot.get("sessions", {}).get(session_id)
187+
# If any file in this session is in this context
188+
if any(f['context_uid'] == c_uid for f in session['files']):
189+
result.append((session_id, persistent_to_native(session)))
190+
return sorted(result)
191+
192+
def esign_session_html(self, session_data):
193+
"""Renders esign session annot in HTML"""
194+
return self._render_value(session_data)

src/imio/esign/browser/configure.zcml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,14 @@
112112
allowed_attributes="available"
113113
/>
114114

115+
<browser:page
116+
for="*"
117+
name="session-annotation-info"
118+
class=".actions.SessionAnnotationInfoView"
119+
permission="cmf.ManagePortal"
120+
template="templates/session_annotation_info.pt"
121+
/>
122+
115123
<browser:page
116124
name="signing-users-csv"
117125
for="Products.CMFPlone.interfaces.IPloneSiteRoot"
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en"
2+
xmlns:tal="http://xml.zope.org/namespaces/tal"
3+
xmlns:metal="http://xml.zope.org/namespaces/metal"
4+
xmlns:i18n="http://xml.zope.org/namespaces/i18n"
5+
metal:use-macro="context/main_template/macros/master"
6+
i18n:domain="imio.esign">
7+
<body>
8+
<metal:bodytext fill-slot="content-core">
9+
<tal:esign tal:repeat="session view/esign_sessions">
10+
<h2 tal:content="python:'imio.esign session (id: {})'.format(session[0])"></h2>
11+
<pre style="background: #f5f5f5; color: #333; padding: 1em; overflow: auto;"
12+
tal:content="structure python:view.esign_session_html(session[1])" />
13+
</tal:esign>
14+
</metal:bodytext>
15+
</body>
16+
</html>

src/imio/esign/tests/test_actions.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# -*- coding: utf-8 -*-
22
"""actions tests for this package."""
33
from collective.iconifiedcategory.utils import calculate_category_id
4+
from imio.esign.browser.actions import SessionAnnotationInfoView
45
from imio.esign.testing import IMIO_ESIGN_INTEGRATION_TESTING
56
from imio.esign.utils import add_files_to_session
67
from imio.esign.utils import get_session_annotation
@@ -124,3 +125,208 @@ def test_finished_shows_message_and_redirects(self):
124125
self.assertEqual(len(messages), 1)
125126
self.assertIn("removed from session", messages[0].message)
126127
self.assertEqual(self.request.RESPONSE.getHeader("location"), self.annexes[0].absolute_url())
128+
129+
130+
class TestSessionAnnotationInfoView(unittest.TestCase):
131+
"""Test SessionAnnotationInfoView"""
132+
133+
layer = IMIO_ESIGN_INTEGRATION_TESTING
134+
135+
def setUp(self):
136+
self.portal = self.layer["portal"]
137+
change_user(self.portal)
138+
self.om1 = get_object(oid="reponse1", ptype="dmsoutgoingmail")
139+
self.view = SessionAnnotationInfoView(self.om1, self.portal.REQUEST)
140+
self.pf = self.portal["contacts"]["personnel-folder"]
141+
self.pgof = self.portal["contacts"]["plonegroup-organization"]
142+
143+
def _setup_esign_omail(self):
144+
"""Create a new outgoing mail with esign enabled and two files."""
145+
login(self.layer["app"], "admin")
146+
self.portal.portal_setup.runImportStepFromProfile(
147+
"profile-imio.dms.mail:singles", "imiodmsmail-activate-esigning", run_dependencies=False
148+
)
149+
set_registry_file_url("https://downloads.files.com")
150+
intids = getUtility(IIntIds)
151+
params = {
152+
"title": u"Courrier test esign",
153+
"internal_reference_no": internalReferenceOutgoingMailDefaultValue(
154+
DummyView(self.portal, self.portal.REQUEST)
155+
),
156+
"mail_type": "courrier",
157+
"treating_groups": self.pgof["direction-generale"]["grh"].UID(),
158+
"recipients": [RelationValue(intids.getId(self.portal["contacts"]["jeancourant"]))],
159+
"assigned_user": "agent",
160+
"sender": self.portal["contacts"]["jeancourant"]["agent-electrabel"].UID(),
161+
"send_modes": u"post",
162+
"signers": [
163+
{
164+
"number": 1,
165+
"signer": self.pf["dirg"]["directeur-general"].UID(),
166+
"approvings": [u"_themself_"],
167+
"editor": True,
168+
},
169+
{
170+
"number": 2,
171+
"signer": self.pf["bourgmestre"]["bourgmestre"].UID(),
172+
"approvings": [u"_themself_"],
173+
"editor": False,
174+
},
175+
],
176+
"esign": True,
177+
}
178+
omail = sub_create(self.portal["outgoing-mail"], "dmsoutgoingmail", datetime.now(), "om-esign", **params)
179+
filename = u"Réponse salle.odt"
180+
ct = self.portal["annexes_types"]["outgoing_dms_files"]["outgoing-dms-file"]
181+
files = []
182+
for i in range(2):
183+
with open("%s/batchimport/toprocess/outgoing-mail/%s" % (PRODUCT_DIR, filename), "rb") as fo:
184+
file_object = NamedBlobFile(fo.read(), filename=filename)
185+
files.append(
186+
createContentInContainer(
187+
omail,
188+
"dmsommainfile",
189+
id="file%s" % i,
190+
scan_id="012999900000601",
191+
file=file_object,
192+
content_category=calculate_category_id(ct),
193+
)
194+
)
195+
return omail, files, IOMApproval(omail)
196+
197+
def _approve_all_files(self, omail, files, approval):
198+
"""Approve all files through the full two-signer approval process."""
199+
pw = self.portal.portal_workflow
200+
pw.doActionFor(omail, "propose_to_approve")
201+
approval.approve_file(files[0], "dirg", transition="propose_to_be_signed")
202+
approval.approve_file(files[1], "dirg", transition="propose_to_be_signed")
203+
approval.approve_file(files[1], "bourgmestre", transition="propose_to_be_signed")
204+
approval.approve_file(files[0], "bourgmestre", transition="propose_to_be_signed")
205+
206+
def test_call(self):
207+
with self.assertRaises(Unauthorized):
208+
self.view()
209+
login(self.portal.aq_parent, "admin")
210+
self.assertIsInstance(self.view(), basestring)
211+
212+
def test_render_value(self):
213+
# Dict
214+
self.assertEqual(self.view._render_value({}), u"{}")
215+
self.assertEqual(
216+
self.view._render_value({"key": "val"}),
217+
u"{\n &#x27;key&#x27;: &#x27;val&#x27;,\n}",
218+
)
219+
220+
# Indentation: nested value increases indent level
221+
self.assertEqual(
222+
self.view._render_value({"key": ["a"]}),
223+
u"{\n &#x27;key&#x27;: [\n &#x27;a&#x27;,\n ],\n}",
224+
)
225+
226+
# List
227+
self.assertEqual(self.view._render_value([]), u"[]")
228+
self.assertEqual(
229+
self.view._render_value(["a", "b"]),
230+
u"[\n &#x27;a&#x27;,\n &#x27;b&#x27;,\n]",
231+
)
232+
233+
# Tuple
234+
self.assertEqual(self.view._render_value(()), u"[]")
235+
236+
# String
237+
self.assertEqual(self.view._render_value(u"hello"), u"u&#x27;hello&#x27;")
238+
239+
# UID not found
240+
fake_uid = u"a" * 32
241+
self.assertEqual(
242+
self.view._render_value(fake_uid),
243+
u"<span title='not found'>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</span>",
244+
)
245+
246+
# UID found
247+
self.assertEqual(
248+
self.view._render_value(self.om1.UID()),
249+
u"<a href='http://nohost/plone/outgoing-mail/202608/reponse1' title='/plone/outgoing-mail/202608/reponse1'>R\xe9ponse 1</a>",
250+
)
251+
252+
def test_uid_to_link(self):
253+
uid = self.om1.UID()
254+
result = self.view._uid_to_link(uid)
255+
self.assertIn(u"<a href=", result)
256+
self.assertIn(self.om1.absolute_url(), result)
257+
258+
result = self.view._uid_to_link(u"a" * 32)
259+
self.assertIn(u"<span", result)
260+
self.assertIn(u"not found", result)
261+
262+
def test_esign_sessions(self):
263+
"""Test approval_annot_html and esign_session_html after a true approval process."""
264+
omail, files, approval = self._setup_esign_omail()
265+
self._approve_all_files(omail, files, approval)
266+
267+
view = SessionAnnotationInfoView(omail, self.portal.REQUEST)
268+
269+
# esign essions property
270+
esign_sessions = view.esign_sessions
271+
self.assertEqual(len(esign_sessions), 1)
272+
esign_session = esign_sessions[0]
273+
self.assertIsInstance(esign_session, tuple)
274+
self.assertEqual(esign_session[0], 0)
275+
276+
# esign session html
277+
self.assertEqual(
278+
HTMLParser().unescape(view.esign_session_html(esign_session[1])),
279+
u"""{{
280+
'acroform': True,
281+
'client_id': '0129999',
282+
'discriminators': [],
283+
'files': [
284+
{{
285+
'context_uid': <a href='http://nohost/plone/outgoing-mail/{folder_name}/om-esign' title='/plone/outgoing-mail/{folder_name}/om-esign'>Courrier test esign</a>,
286+
'filename': u'R\\xe9ponse salle__{pdf1_uid}.pdf',
287+
'scan_id': '012999900000601',
288+
'status': '',
289+
'title': u'R\\xe9ponse salle.pdf',
290+
'uid': <a href='http://nohost/plone/outgoing-mail/{folder_name}/om-esign/reponse-salle.pdf' title='/plone/outgoing-mail/{folder_name}/om-esign/reponse-salle.pdf'>Réponse salle.pdf</a>,
291+
}},
292+
{{
293+
'context_uid': <a href='http://nohost/plone/outgoing-mail/{folder_name}/om-esign' title='/plone/outgoing-mail/{folder_name}/om-esign'>Courrier test esign</a>,
294+
'filename': u'R\\xe9ponse salle__{pdf2_uid}.pdf',
295+
'scan_id': '012999900000601',
296+
'status': '',
297+
'title': u'R\\xe9ponse salle.pdf',
298+
'uid': <a href='http://nohost/plone/outgoing-mail/{folder_name}/om-esign/reponse-salle-1.pdf' title='/plone/outgoing-mail/{folder_name}/om-esign/reponse-salle-1.pdf'>Réponse salle.pdf</a>,
299+
}},
300+
],
301+
'last_update': {last_update},
302+
'returns': [],
303+
'seal': False,
304+
'sign_id': '012999900000',
305+
'sign_url': None,
306+
'signers': [
307+
{{
308+
'email': 'dirg@macommune.be',
309+
'fullname': u'Maxime DG',
310+
'position': u'Directeur G\\xe9n\\xe9ral',
311+
'status': '',
312+
'userid': 'dirg',
313+
}},
314+
{{
315+
'email': 'bourgmestre@macommune.be',
316+
'fullname': u'Paul BM',
317+
'position': u'Bourgmestre',
318+
'status': '',
319+
'userid': 'bourgmestre',
320+
}},
321+
],
322+
'size': 54660,
323+
'state': 'draft',
324+
'title': u'[ia.docs] Session 012999900000',
325+
'watchers': [],
326+
}}""".format(
327+
pdf1_uid=api.content.get(omail.absolute_url_path() + "/reponse-salle.pdf").UID(),
328+
pdf2_uid=api.content.get(omail.absolute_url_path() + "/reponse-salle-1.pdf").UID(),
329+
folder_name=omail.__parent__.__name__,
330+
last_update=repr(get_session_annotation()["sessions"][0]["last_update"]),
331+
),
332+
)

0 commit comments

Comments
 (0)