Skip to content

Commit 31cef0e

Browse files
committed
Added SessionAnnotationInfoView
1 parent 94c7b5e commit 31cef0e

File tree

6 files changed

+282
-0
lines changed

6 files changed

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

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)