Skip to content

Commit 204890f

Browse files
committed
Added SessionAnnotationInfoView
1 parent 94c7b5e commit 204890f

File tree

6 files changed

+292
-0
lines changed

6 files changed

+292
-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: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
11
# -*- coding: utf-8 -*-
2+
from AccessControl import Unauthorized
3+
from html import escape
4+
from imio.esign.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
16+
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
17+
18+
import pprint
19+
import re
1020

1121

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

src/imio/esign/browser/configure.zcml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,13 @@
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+
/>
121+
115122
<browser:page
116123
name="signing-users-csv"
117124
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: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
# -*- coding: utf-8 -*-
22
"""actions tests for this package."""
3+
from AccessControl import Unauthorized
34
from collective.iconifiedcategory.utils import calculate_category_id
5+
from imio.esign.browser.actions import SessionAnnotationInfoView
46
from imio.esign.testing import IMIO_ESIGN_INTEGRATION_TESTING
57
from imio.esign.utils import add_files_to_session
68
from imio.esign.utils import get_session_annotation
79
from plone import api
10+
from plone.app.testing import login
811
from plone.app.testing import setRoles
912
from plone.app.testing import TEST_USER_ID
1013
from plone.namedfile.file import NamedBlobFile
@@ -17,6 +20,17 @@
1720
import unittest
1821

1922

23+
try:
24+
from html import unescape
25+
except ImportError: # Python 2
26+
from HTMLParser import HTMLParser
27+
unescape = HTMLParser().unescape
28+
29+
try:
30+
string_types = (basestring,)
31+
except NameError:
32+
string_types = (str,)
33+
2034
class TestRemoveItemFromSessionView(unittest.TestCase):
2135
"""Test RemoveItemFromSessionView browser view."""
2236

@@ -124,3 +138,173 @@ def test_finished_shows_message_and_redirects(self):
124138
self.assertEqual(len(messages), 1)
125139
self.assertIn("removed from session", messages[0].message)
126140
self.assertEqual(self.request.RESPONSE.getHeader("location"), self.annexes[0].absolute_url())
141+
142+
143+
class TestSessionAnnotationInfoView(unittest.TestCase):
144+
"""Test SessionAnnotationInfoView"""
145+
146+
layer = IMIO_ESIGN_INTEGRATION_TESTING
147+
148+
def setUp(self):
149+
self.portal = self.layer["portal"]
150+
setRoles(self.portal, TEST_USER_ID, ["Manager"])
151+
at_folder = api.content.create(
152+
container=self.portal,
153+
id="annexes_types",
154+
title="Annexes Types",
155+
type="ContentCategoryConfiguration",
156+
exclude_from_nav=True,
157+
)
158+
category_group = api.content.create(
159+
type="ContentCategoryGroup",
160+
title="Annexes",
161+
container=at_folder,
162+
id="annexes",
163+
)
164+
icon_path = os.path.join(os.path.dirname(collective.iconifiedcategory.__file__), "tests", "icône1.png")
165+
with open(icon_path, "rb") as fl:
166+
api.content.create(
167+
type="ContentCategory",
168+
title="To sign",
169+
container=category_group,
170+
icon=NamedBlobImage(fl.read(), filename=u"icône1.png"),
171+
id="to_sign",
172+
predefined_title="To be signed",
173+
to_sign=True,
174+
show_preview=False,
175+
)
176+
self.folder = api.content.create(
177+
container=self.portal, type="Folder", id="test_session_folder", title="Test Session Folder"
178+
)
179+
tests_dir = os.path.dirname(__file__)
180+
self.annexes = []
181+
for i in range(2):
182+
with open(os.path.join(tests_dir, "annex1.pdf"), "rb") as f:
183+
annex = api.content.create(
184+
container=self.folder,
185+
type="annex",
186+
id="annex{}".format(i),
187+
title=u"Annex {}".format(i),
188+
content_category=calculate_category_id(self.portal["annexes_types"]["annexes"]["to_sign"]),
189+
scan_id="012345600000{:02d}".format(i),
190+
file=NamedBlobFile(data=f.read(), filename=u"annex{}.pdf".format(i), contentType="application/pdf"),
191+
)
192+
self.annexes.append(annex)
193+
self.signers = [
194+
("user1", "user1@sign.com", u"User 1", u"Position 1"),
195+
("user2", "user2@sign.com", u"User 2", u"Position 2"),
196+
]
197+
self.view = SessionAnnotationInfoView(self.folder, self.portal.REQUEST)
198+
199+
def test_call(self):
200+
setRoles(self.portal, TEST_USER_ID, ["Member"])
201+
with self.assertRaises(Unauthorized):
202+
self.view()
203+
login(self.layer["app"], "admin")
204+
self.assertIsInstance(self.view(), string_types)
205+
206+
def test_render_value(self):
207+
# Dict
208+
self.assertEqual(self.view._render_value({}), u"{}")
209+
self.assertEqual(
210+
self.view._render_value({"key": "val"}),
211+
u"{\n &#x27;key&#x27;: &#x27;val&#x27;,\n}",
212+
)
213+
214+
# Indentation: nested value increases indent level
215+
self.assertEqual(
216+
self.view._render_value({"key": ["a"]}),
217+
u"{\n &#x27;key&#x27;: [\n &#x27;a&#x27;,\n ],\n}",
218+
)
219+
220+
# List
221+
self.assertEqual(self.view._render_value([]), u"[]")
222+
self.assertEqual(
223+
self.view._render_value(["a", "b"]),
224+
u"[\n &#x27;a&#x27;,\n &#x27;b&#x27;,\n]",
225+
)
226+
227+
# Tuple
228+
self.assertEqual(self.view._render_value(()), u"[]")
229+
230+
# String
231+
self.assertEqual(self.view._render_value(u"hello"), u"u&#x27;hello&#x27;")
232+
233+
def test_uid_to_link(self):
234+
uid = self.folder.UID()
235+
result = self.view._uid_to_link(uid)
236+
self.assertEqual(
237+
result,
238+
u"<a href='http://nohost/plone/test_session_folder/view' title='/plone/test_session_folder'>Test Session Folder</a>",
239+
)
240+
241+
result = self.view._uid_to_link(u"a" * 32)
242+
self.assertEqual(
243+
result,
244+
u"<span title='not found'>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</span>",
245+
)
246+
247+
def test_esign_sessions(self):
248+
uids = [a.UID() for a in self.annexes]
249+
add_files_to_session(self.signers, uids, title=u"[ia.parapheo] Session {sign_id}")
250+
view = SessionAnnotationInfoView(self.folder, self.portal.REQUEST)
251+
252+
esign_sessions = view.esign_sessions
253+
self.assertEqual(len(esign_sessions), 1)
254+
esign_session = esign_sessions[0]
255+
self.assertIsInstance(esign_session, tuple)
256+
self.assertEqual(esign_session[0], 0)
257+
258+
self.assertEqual(
259+
unescape(view.esign_session_html(esign_session[1])),
260+
u"""{{
261+
'acroform': True,
262+
'client_id': '0123456',
263+
'discriminators': [],
264+
'files': [
265+
{{
266+
'context_uid': <a href='http://nohost/plone/test_session_folder/view' title='/plone/test_session_folder'>Test Session Folder</a>,
267+
'filename': u'annex0.pdf',
268+
'scan_id': '01234560000000',
269+
'status': '',
270+
'title': u'Annex 0',
271+
'uid': <a href='http://nohost/plone/test_session_folder/annex0/view' title='/plone/test_session_folder/annex0'>Annex 0</a>,
272+
}},
273+
{{
274+
'context_uid': <a href='http://nohost/plone/test_session_folder/view' title='/plone/test_session_folder'>Test Session Folder</a>,
275+
'filename': u'annex1.pdf',
276+
'scan_id': '01234560000001',
277+
'status': '',
278+
'title': u'Annex 1',
279+
'uid': <a href='http://nohost/plone/test_session_folder/annex1/view' title='/plone/test_session_folder/annex1'>Annex 1</a>,
280+
}},
281+
],
282+
'last_update': {},
283+
'returns': [],
284+
'seal': None,
285+
'sign_id': '012345600000',
286+
'sign_url': None,
287+
'signers': [
288+
{{
289+
'email': 'user1@sign.com',
290+
'fullname': u'User 1',
291+
'position': u'Position 1',
292+
'status': '',
293+
'userid': 'user1',
294+
}},
295+
{{
296+
'email': 'user2@sign.com',
297+
'fullname': u'User 2',
298+
'position': u'Position 2',
299+
'status': '',
300+
'userid': 'user2',
301+
}},
302+
],
303+
'size': 13936,
304+
'state': 'draft',
305+
'title': u'[ia.parapheo] Session 012345600000',
306+
'watchers': [],
307+
}}""".format(
308+
repr(esign_session[1]['last_update']),
309+
),
310+
)

src/imio/esign/utils.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,15 @@ def get_suid_from_uuid(uid):
466466
return shortuid_encode_id(uid, separator="-", block_size=5)
467467

468468

469+
def persistent_to_native(value):
470+
"""Convert persistent object to native object recursively."""
471+
if isinstance(value, (PersistentMapping, dict)):
472+
return {k: persistent_to_native(v) for k, v in value.items()}
473+
elif isinstance(value, (PersistentList, list, tuple)):
474+
return [persistent_to_native(v) for v in value]
475+
return value
476+
477+
469478
def get_state_description(state):
470479
"""
471480
Get a human readable description for a given session state.

0 commit comments

Comments
 (0)