Skip to content

Commit 8fa7b44

Browse files
committed
Added SessionAnnotationInfoView
1 parent f49a996 commit 8fa7b44

File tree

6 files changed

+297
-0
lines changed

6 files changed

+297
-0
lines changed

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ Changelog
4343
[gbastien]
4444
- Use `@CachedProperty` for `FacetedSessionInfoViewlet.sessions` and `ItemSessionInfoViewlet.sessions`.
4545
[gbastien]
46+
- Added SessionAnnotationInfoView.
47+
[chris-adam]
4648

4749
1.0a2 (2026-02-06)
4850
------------------

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

38+
39+
class TestRemoveItemFromSessionView(unittest.TestCase):
40+
"""Test RemoveItemFromSessionView browser view."""
41+
2342
layer = IMIO_ESIGN_INTEGRATION_TESTING
2443

2544
def setUp(self):
@@ -152,3 +171,173 @@ def test_available(self):
152171
view()
153172
self.assertFalse(annot["sessions"])
154173
self.assertFalse(view.available())
174+
175+
176+
class TestSessionAnnotationInfoView(unittest.TestCase):
177+
"""Test SessionAnnotationInfoView"""
178+
179+
layer = IMIO_ESIGN_INTEGRATION_TESTING
180+
181+
def setUp(self):
182+
self.portal = self.layer["portal"]
183+
setRoles(self.portal, TEST_USER_ID, ["Manager"])
184+
at_folder = api.content.create(
185+
container=self.portal,
186+
id="annexes_types",
187+
title="Annexes Types",
188+
type="ContentCategoryConfiguration",
189+
exclude_from_nav=True,
190+
)
191+
category_group = api.content.create(
192+
type="ContentCategoryGroup",
193+
title="Annexes",
194+
container=at_folder,
195+
id="annexes",
196+
)
197+
icon_path = os.path.join(os.path.dirname(collective.iconifiedcategory.__file__), "tests", "icône1.png")
198+
with open(icon_path, "rb") as fl:
199+
api.content.create(
200+
type="ContentCategory",
201+
title="To sign",
202+
container=category_group,
203+
icon=NamedBlobImage(fl.read(), filename=u"icône1.png"),
204+
id="to_sign",
205+
predefined_title="To be signed",
206+
to_sign=True,
207+
show_preview=False,
208+
)
209+
self.folder = api.content.create(
210+
container=self.portal, type="Folder", id="test_session_folder", title="Test Session Folder"
211+
)
212+
tests_dir = os.path.dirname(__file__)
213+
self.annexes = []
214+
for i in range(2):
215+
with open(os.path.join(tests_dir, "annex1.pdf"), "rb") as f:
216+
annex = api.content.create(
217+
container=self.folder,
218+
type="annex",
219+
id="annex{}".format(i),
220+
title=u"Annex {}".format(i),
221+
content_category=calculate_category_id(self.portal["annexes_types"]["annexes"]["to_sign"]),
222+
scan_id="012345600000{:02d}".format(i),
223+
file=NamedBlobFile(data=f.read(), filename=u"annex{}.pdf".format(i), contentType="application/pdf"),
224+
)
225+
self.annexes.append(annex)
226+
self.signers = [
227+
("user1", "user1@sign.com", u"User 1", u"Position 1"),
228+
("user2", "user2@sign.com", u"User 2", u"Position 2"),
229+
]
230+
self.view = SessionAnnotationInfoView(self.folder, self.portal.REQUEST)
231+
232+
def test_call(self):
233+
setRoles(self.portal, TEST_USER_ID, ["Member"])
234+
with self.assertRaises(Unauthorized):
235+
self.view()
236+
login(self.layer["app"], "admin")
237+
self.assertIsInstance(self.view(), string_types)
238+
239+
def test_render_value(self):
240+
# Dict
241+
self.assertEqual(self.view._render_value({}), u"{}")
242+
self.assertEqual(
243+
self.view._render_value({"key": "val"}),
244+
u"{\n &#x27;key&#x27;: &#x27;val&#x27;,\n}",
245+
)
246+
247+
# Indentation: nested value increases indent level
248+
self.assertEqual(
249+
self.view._render_value({"key": ["a"]}),
250+
u"{\n &#x27;key&#x27;: [\n &#x27;a&#x27;,\n ],\n}",
251+
)
252+
253+
# List
254+
self.assertEqual(self.view._render_value([]), u"[]")
255+
self.assertEqual(
256+
self.view._render_value(["a", "b"]),
257+
u"[\n &#x27;a&#x27;,\n &#x27;b&#x27;,\n]",
258+
)
259+
260+
# Tuple
261+
self.assertEqual(self.view._render_value(()), u"[]")
262+
263+
# String
264+
self.assertEqual(self.view._render_value(u"hello"), u"u&#x27;hello&#x27;")
265+
266+
def test_uid_to_link(self):
267+
uid = self.folder.UID()
268+
result = self.view._uid_to_link(uid)
269+
self.assertEqual(
270+
result,
271+
u"<a href='http://nohost/plone/test_session_folder/view' title='/plone/test_session_folder'>Test Session Folder</a>",
272+
)
273+
274+
result = self.view._uid_to_link(u"a" * 32)
275+
self.assertEqual(
276+
result,
277+
u"<span title='not found'>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</span>",
278+
)
279+
280+
def test_esign_sessions(self):
281+
uids = [a.UID() for a in self.annexes]
282+
add_files_to_session(self.signers, uids, title=u"[ia.parapheo] Session {sign_id}")
283+
view = SessionAnnotationInfoView(self.folder, self.portal.REQUEST)
284+
285+
esign_sessions = view.esign_sessions
286+
self.assertEqual(len(esign_sessions), 1)
287+
esign_session = esign_sessions[0]
288+
self.assertIsInstance(esign_session, tuple)
289+
self.assertEqual(esign_session[0], 0)
290+
291+
self.assertEqual(
292+
unescape(view.esign_session_html(esign_session[1])),
293+
u"""{{
294+
'acroform': True,
295+
'client_id': '0123456',
296+
'discriminators': [],
297+
'files': [
298+
{{
299+
'context_uid': <a href='http://nohost/plone/test_session_folder/view' title='/plone/test_session_folder'>Test Session Folder</a>,
300+
'filename': u'annex0.pdf',
301+
'scan_id': '01234560000000',
302+
'status': '',
303+
'title': u'Annex 0',
304+
'uid': <a href='http://nohost/plone/test_session_folder/annex0/view' title='/plone/test_session_folder/annex0'>Annex 0</a>,
305+
}},
306+
{{
307+
'context_uid': <a href='http://nohost/plone/test_session_folder/view' title='/plone/test_session_folder'>Test Session Folder</a>,
308+
'filename': u'annex1.pdf',
309+
'scan_id': '01234560000001',
310+
'status': '',
311+
'title': u'Annex 1',
312+
'uid': <a href='http://nohost/plone/test_session_folder/annex1/view' title='/plone/test_session_folder/annex1'>Annex 1</a>,
313+
}},
314+
],
315+
'last_update': {},
316+
'returns': [],
317+
'seal': None,
318+
'sign_id': '012345600000',
319+
'sign_url': None,
320+
'signers': [
321+
{{
322+
'email': 'user1@sign.com',
323+
'fullname': u'User 1',
324+
'position': u'Position 1',
325+
'status': '',
326+
'userid': 'user1',
327+
}},
328+
{{
329+
'email': 'user2@sign.com',
330+
'fullname': u'User 2',
331+
'position': u'Position 2',
332+
'status': '',
333+
'userid': 'user2',
334+
}},
335+
],
336+
'size': 13936,
337+
'state': 'draft',
338+
'title': u'[ia.parapheo] Session 012345600000',
339+
'watchers': [],
340+
}}""".format(
341+
repr(esign_session[1]['last_update']),
342+
),
343+
)

src/imio/esign/utils.py

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

502502

503+
def persistent_to_native(value):
504+
"""Convert persistent object to native object recursively."""
505+
if isinstance(value, (PersistentMapping, dict)):
506+
return {k: persistent_to_native(v) for k, v in value.items()}
507+
elif isinstance(value, (PersistentList, list, tuple)):
508+
return [persistent_to_native(v) for v in value]
509+
return value
510+
511+
503512
def get_state_description(state):
504513
"""
505514
Get a human readable description for a given session state.

0 commit comments

Comments
 (0)