Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ Changelog
[chris-adam]
- Added external watchers for esign sessions.
[chris-adam, sgeulette]
- Added SessionAnnotationInfoView.
[chris-adam]

1.0a2 (2026-02-06)
------------------
Expand Down
74 changes: 74 additions & 0 deletions src/imio/esign/browser/actions.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
# -*- coding: utf-8 -*-
from AccessControl import Unauthorized
from html import escape
from imio.esign.utils import persistent_to_native
from imio.esign import _
from imio.esign.adapters import ISignable
from imio.esign.utils import add_files_to_session
from imio.esign.utils import get_session_annotation
from imio.esign.utils import remove_context_from_session
from imio.esign.utils import remove_files_from_session
from imio.helpers.content import uuidToObject
from imio.helpers.security import check_zope_admin
from plone import api
from Products.CMFPlone.utils import safe_unicode
from Products.Five import BrowserView
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile

import pprint
import re


class AddToSessionView(BrowserView):
Expand Down Expand Up @@ -121,3 +131,67 @@ def available(self):
"""Defines if the action is available or not."""
annot = get_session_annotation()
return self.context.UID() in annot.get("uids", {})


class SessionAnnotationInfoView(BrowserView):
"""Admin-only view displaying imio.esign session annotations for a specific context item."""

index = ViewPageTemplateFile("templates/session_annotation_info.pt")

def __call__(self):
if not check_zope_admin():
raise Unauthorized
return self.index()

def _uid_to_link(self, uid):
"""Return an HTML link for an object UID, or the UID if not found."""
obj = uuidToObject(uid, unrestricted=True)
if obj is None:
return u"<span title='not found'>{}</span>".format(safe_unicode(uid))
url = escape(obj.absolute_url() + "/view", quote=True)
path = escape(u"/".join(obj.getPhysicalPath()))
title = escape(safe_unicode(getattr(obj, "title", "") or path))
return u"<a href='{}' title='{}'>{}</a>".format(url, path, title)

def _render_value(self, value, indent=u""):
"""Render a value, replacing UIDs with links where possible."""
inner = indent + u" "
if isinstance(value, dict):
if not value:
return u"{}"
lines = [u"{"]
for k, v in sorted(value.items()):
key = escape(safe_unicode(pprint.pformat(k)))
lines.append(u"{}{}: {},".format(inner, key, self._render_value(v, inner)))
lines.append(u"{}}}".format(indent))
return u"\n".join(lines)
elif isinstance(value, (list, tuple)):
if not value:
return u"[]"
lines = [u"["]
for item in value:
lines.append(u"{}{},".format(inner, self._render_value(item, inner)))
lines.append(u"{}]".format(indent))
return u"\n".join(lines)
elif isinstance(value, basestring) and re.match(r"^[0-9a-f]{32}$", value):
# Looks like a UUID
return self._uid_to_link(value)
else:
return escape(safe_unicode(pprint.pformat(value)))
Comment on lines +176 to +180
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

basestring is undefined in Python 3.

This will raise a NameError when running under Python 3. Use a compatibility check:

🐛 Proposed fix

Add at the top of the file (after other imports):

try:
    string_types = (basestring,)
except NameError:
    string_types = (str,)

Then update line 173:

-        elif isinstance(value, basestring) and re.match(r"^[0-9a-f]{32}$", value):
+        elif isinstance(value, string_types) and re.match(r"^[0-9a-f]{32}$", value):
🧰 Tools
🪛 Ruff (0.15.4)

[error] 173-173: Undefined name basestring

(F821)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/imio/esign/browser/actions.py` around lines 173 - 177, The code uses
basestring which is undefined in Python 3; add a compatibility alias (e.g.,
define string_types = (basestring,) in a try/except that falls back to (str,)
near the top of the file, then replace the isinstance check in the method
containing the snippet—where value is checked and self._uid_to_link is
called—with isinstance(value, string_types) so the UUID branch works on both Py2
and Py3.


@property
def esign_sessions(self):
"""Return list of (session_id, session_data) for all sessions."""
annot = get_session_annotation()
c_uid = self.context.UID()
result = []
for session_id in annot['sessions']:
session = annot.get("sessions", {}).get(session_id)
# If any file in this session is in this context
if any(f['context_uid'] == c_uid for f in session['files']):
result.append((session_id, persistent_to_native(session)))
return sorted(result)
Comment on lines +182 to +193
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
sed -n '115,121p' src/imio/esign/browser/configure.zcml
sed -n '179,190p' src/imio/esign/browser/actions.py
rg -n 'name="session-annotation-info"|for="\*"|UID\(' src/imio/esign/browser/configure.zcml src/imio/esign/browser/actions.py

Repository: IMIO/imio.esign

Length of output: 1649


Guard contexts that do not expose UID().

This property assumes every traversal target has UID(), but the page is registered for for="*". Hitting @@session-annotation-info on the site root or other non-content contexts will crash with AttributeError instead of rendering an empty result. Add a guard or narrow the registration.

Suggested fix
     `@property`
     def esign_sessions(self):
         """Return list of (session_id, session_data) for all sessions."""
+        uid_getter = getattr(self.context, "UID", None)
+        if uid_getter is None:
+            return []
         annot = get_session_annotation()
-        c_uid = self.context.UID()
+        c_uid = uid_getter()
         result = []
-        for session_id in annot['sessions']:
-            session = annot.get("sessions", {}).get(session_id)
+        for session_id, session in annot.get("sessions", {}).items():
             # If any file in this session is in this context
             if any(f['context_uid'] == c_uid for f in session['files']):
                 result.append((session_id, persistent_to_native(session)))
         return sorted(result)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/imio/esign/browser/actions.py` around lines 179 - 190, The esign_sessions
property assumes self.context.UID() exists and will raise AttributeError for
contexts without UID; update esign_sessions to guard against missing UID by
checking hasattr(self.context, "UID") or using getattr(self.context, "UID",
None) and returning an empty sorted list if no UID is present before calling
get_session_annotation/persistent_to_native; locate the esign_sessions method
and add that guard (or alternatively narrow the view registration) so it safely
handles site-root or non-content contexts.


def esign_session_html(self, session_data):
"""Renders esign session annot in HTML"""
return self._render_value(session_data)
7 changes: 7 additions & 0 deletions src/imio/esign/browser/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@
allowed_attributes="available"
/>

<browser:page
for="*"
name="session-annotation-info"
class=".actions.SessionAnnotationInfoView"
permission="cmf.ManagePortal"
/>

<browser:page
name="signing-users-csv"
for="Products.CMFPlone.interfaces.IPloneSiteRoot"
Expand Down
16 changes: 16 additions & 0 deletions src/imio/esign/browser/templates/session_annotation_info.pt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en"
xmlns:tal="http://xml.zope.org/namespaces/tal"
xmlns:metal="http://xml.zope.org/namespaces/metal"
xmlns:i18n="http://xml.zope.org/namespaces/i18n"
metal:use-macro="context/main_template/macros/master"
i18n:domain="imio.esign">
<body>
<metal:bodytext fill-slot="content-core">
<tal:esign tal:repeat="session view/esign_sessions">
<h2 tal:content="python:'imio.esign session (id: {})'.format(session[0])"></h2>
<pre style="background: #f5f5f5; color: #333; padding: 1em; overflow: auto;"
tal:content="structure python:view.esign_session_html(session[1])" />
</tal:esign>
</metal:bodytext>
</body>
</html>
184 changes: 184 additions & 0 deletions src/imio/esign/tests/test_actions.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# -*- coding: utf-8 -*-
"""actions tests for this package."""
from AccessControl import Unauthorized
from collective.iconifiedcategory.utils import calculate_category_id
from imio.esign.browser.actions import SessionAnnotationInfoView
from imio.esign.testing import IMIO_ESIGN_INTEGRATION_TESTING
from imio.esign.utils import add_files_to_session
from imio.esign.utils import get_session_annotation
from plone import api
from plone.app.testing import login
from plone.app.testing import setRoles
from plone.app.testing import TEST_USER_ID
from plone.namedfile.file import NamedBlobFile
Expand All @@ -17,6 +20,17 @@
import unittest


try:
from html import unescape
except ImportError: # Python 2
from HTMLParser import HTMLParser
unescape = HTMLParser().unescape

try:
string_types = (basestring,)
except NameError:
string_types = (str,)

class TestRemoveItemFromSessionView(unittest.TestCase):
"""Test RemoveItemFromSessionView browser view."""

Expand Down Expand Up @@ -124,3 +138,173 @@ def test_finished_shows_message_and_redirects(self):
self.assertEqual(len(messages), 1)
self.assertIn("removed from session", messages[0].message)
self.assertEqual(self.request.RESPONSE.getHeader("location"), self.annexes[0].absolute_url())


class TestSessionAnnotationInfoView(unittest.TestCase):
"""Test SessionAnnotationInfoView"""

layer = IMIO_ESIGN_INTEGRATION_TESTING

def setUp(self):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gbastien Je sais que tu m'as dit de mettre les contenus d'exemple pour les tests dans la layer. Je gérerai ça dans une PR séparée

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chris-adam ce n'est pas du tout urgent, en effet quand çà se calmera dans une v3 on pourra refactorer les tests pour les rendre + rapides mais c'est loin d'être prioritaire 👍

self.portal = self.layer["portal"]
setRoles(self.portal, TEST_USER_ID, ["Manager"])
at_folder = api.content.create(
container=self.portal,
id="annexes_types",
title="Annexes Types",
type="ContentCategoryConfiguration",
exclude_from_nav=True,
)
category_group = api.content.create(
type="ContentCategoryGroup",
title="Annexes",
container=at_folder,
id="annexes",
)
icon_path = os.path.join(os.path.dirname(collective.iconifiedcategory.__file__), "tests", "icône1.png")
with open(icon_path, "rb") as fl:
api.content.create(
type="ContentCategory",
title="To sign",
container=category_group,
icon=NamedBlobImage(fl.read(), filename=u"icône1.png"),
id="to_sign",
predefined_title="To be signed",
to_sign=True,
show_preview=False,
)
self.folder = api.content.create(
container=self.portal, type="Folder", id="test_session_folder", title="Test Session Folder"
)
tests_dir = os.path.dirname(__file__)
self.annexes = []
for i in range(2):
with open(os.path.join(tests_dir, "annex1.pdf"), "rb") as f:
annex = api.content.create(
container=self.folder,
type="annex",
id="annex{}".format(i),
title=u"Annex {}".format(i),
content_category=calculate_category_id(self.portal["annexes_types"]["annexes"]["to_sign"]),
scan_id="012345600000{:02d}".format(i),
file=NamedBlobFile(data=f.read(), filename=u"annex{}.pdf".format(i), contentType="application/pdf"),
)
self.annexes.append(annex)
self.signers = [
("user1", "user1@sign.com", u"User 1", u"Position 1"),
("user2", "user2@sign.com", u"User 2", u"Position 2"),
]
self.view = SessionAnnotationInfoView(self.folder, self.portal.REQUEST)

def test_call(self):
setRoles(self.portal, TEST_USER_ID, ["Member"])
with self.assertRaises(Unauthorized):
self.view()
login(self.layer["app"], "admin")
self.assertIsInstance(self.view(), string_types)

def test_render_value(self):
# Dict
self.assertEqual(self.view._render_value({}), u"{}")
self.assertEqual(
self.view._render_value({"key": "val"}),
u"{\n &#x27;key&#x27;: &#x27;val&#x27;,\n}",
)

# Indentation: nested value increases indent level
self.assertEqual(
self.view._render_value({"key": ["a"]}),
u"{\n &#x27;key&#x27;: [\n &#x27;a&#x27;,\n ],\n}",
)

# List
self.assertEqual(self.view._render_value([]), u"[]")
self.assertEqual(
self.view._render_value(["a", "b"]),
u"[\n &#x27;a&#x27;,\n &#x27;b&#x27;,\n]",
)

# Tuple
self.assertEqual(self.view._render_value(()), u"[]")

# String
self.assertEqual(self.view._render_value(u"hello"), u"u&#x27;hello&#x27;")

def test_uid_to_link(self):
uid = self.folder.UID()
result = self.view._uid_to_link(uid)
self.assertEqual(
result,
u"<a href='http://nohost/plone/test_session_folder/view' title='/plone/test_session_folder'>Test Session Folder</a>",
)

result = self.view._uid_to_link(u"a" * 32)
self.assertEqual(
result,
u"<span title='not found'>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</span>",
)

def test_esign_sessions(self):
uids = [a.UID() for a in self.annexes]
add_files_to_session(self.signers, uids, title=u"[ia.parapheo] Session {sign_id}")
view = SessionAnnotationInfoView(self.folder, self.portal.REQUEST)

esign_sessions = view.esign_sessions
self.assertEqual(len(esign_sessions), 1)
esign_session = esign_sessions[0]
self.assertIsInstance(esign_session, tuple)
self.assertEqual(esign_session[0], 0)

self.assertEqual(
unescape(view.esign_session_html(esign_session[1])),
u"""{{
'acroform': True,
'client_id': '0123456',
'discriminators': [],
'files': [
{{
'context_uid': <a href='http://nohost/plone/test_session_folder/view' title='/plone/test_session_folder'>Test Session Folder</a>,
'filename': u'annex0.pdf',
'scan_id': '01234560000000',
'status': '',
'title': u'Annex 0',
'uid': <a href='http://nohost/plone/test_session_folder/annex0/view' title='/plone/test_session_folder/annex0'>Annex 0</a>,
}},
{{
'context_uid': <a href='http://nohost/plone/test_session_folder/view' title='/plone/test_session_folder'>Test Session Folder</a>,
'filename': u'annex1.pdf',
'scan_id': '01234560000001',
'status': '',
'title': u'Annex 1',
'uid': <a href='http://nohost/plone/test_session_folder/annex1/view' title='/plone/test_session_folder/annex1'>Annex 1</a>,
}},
],
'last_update': {},
'returns': [],
'seal': None,
'sign_id': '012345600000',
'sign_url': None,
'signers': [
{{
'email': 'user1@sign.com',
'fullname': u'User 1',
'position': u'Position 1',
'status': '',
'userid': 'user1',
}},
{{
'email': 'user2@sign.com',
'fullname': u'User 2',
'position': u'Position 2',
'status': '',
'userid': 'user2',
}},
],
'size': 13936,
'state': 'draft',
'title': u'[ia.parapheo] Session 012345600000',
'watchers': [],
}}""".format(
repr(esign_session[1]['last_update']),
),
)
9 changes: 9 additions & 0 deletions src/imio/esign/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,15 @@ def get_suid_from_uuid(uid):
return shortuid_encode_id(uid, separator="-", block_size=5)


def persistent_to_native(value):
"""Convert persistent object to native object recursively."""
if isinstance(value, (PersistentMapping, dict)):
return {k: persistent_to_native(v) for k, v in value.items()}
elif isinstance(value, (PersistentList, list, tuple)):
return [persistent_to_native(v) for v in value]
return value


def get_state_description(state):
"""
Get a human readable description for a given session state.
Expand Down