From 9039b53defe8e9a58f2f4216e3ca25e12042aabe Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 19 May 2026 16:41:37 +0200 Subject: [PATCH 1/2] Added action to create a custom session --- CHANGES.rst | 2 + setup.py | 1 + src/imio/esign/browser/configure.zcml | 8 + src/imio/esign/browser/forms.py | 151 ++++++++++++++++++ src/imio/esign/browser/static/esign.css | 17 ++ src/imio/esign/browser/static/esign.js | 11 ++ src/imio/esign/browser/templates/sessions.pt | 5 + src/imio/esign/browser/views.py | 3 + src/imio/esign/configure.zcml | 5 + .../esign/profiles/default/cssregistry.xml | 10 ++ .../esign/profiles/default/jsregistry.xml | 10 ++ src/imio/esign/tests/base.py | 9 ++ src/imio/esign/tests/test_browser_forms.py | 150 +++++++++++++++++ src/imio/esign/tests/test_browser_views.py | 24 +-- src/imio/esign/vocabularies.py | 46 ++++++ 15 files changed, 436 insertions(+), 16 deletions(-) create mode 100644 src/imio/esign/browser/forms.py create mode 100644 src/imio/esign/browser/static/esign.css create mode 100644 src/imio/esign/browser/static/esign.js create mode 100644 src/imio/esign/profiles/default/cssregistry.xml create mode 100644 src/imio/esign/profiles/default/jsregistry.xml create mode 100644 src/imio/esign/tests/test_browser_forms.py create mode 100644 src/imio/esign/vocabularies.py diff --git a/CHANGES.rst b/CHANGES.rst index 7d2867d..2fe84ff 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -9,6 +9,8 @@ Changelog [chris-adam, sgeulette] - Added "Open Paraphéo" button on sessions listing view. [chris-adam] +- Added action to create a custom session. + [chris-adam] 1.0b8 (2026-05-08) ------------------ diff --git a/setup.py b/setup.py index 0f5ea37..0281604 100644 --- a/setup.py +++ b/setup.py @@ -57,6 +57,7 @@ "eea.facetednavigation", "imio.fpaudit", "imio.helpers>1.3.10", + "natsort", "imio.prettylink", "imio.pyutils", "plone.api>=1.8.4", diff --git a/src/imio/esign/browser/configure.zcml b/src/imio/esign/browser/configure.zcml index 87d4558..164f023 100644 --- a/src/imio/esign/browser/configure.zcml +++ b/src/imio/esign/browser/configure.zcml @@ -119,6 +119,14 @@ permission="cmf.ManagePortal" /> + + *', + formselector: '#form', + closeselector: '[name="form.buttons.cancel"]', + noform: function(el, pbo) { + window.location.reload(); + } + }); +}); diff --git a/src/imio/esign/browser/templates/sessions.pt b/src/imio/esign/browser/templates/sessions.pt index c5f3b36..28162fc 100644 --- a/src/imio/esign/browser/templates/sessions.pt +++ b/src/imio/esign/browser/templates/sessions.pt @@ -34,6 +34,11 @@
+ Create custom session + Open eSign platform diff --git a/src/imio/esign/browser/views.py b/src/imio/esign/browser/views.py index fbc32ef..f047b4c 100644 --- a/src/imio/esign/browser/views.py +++ b/src/imio/esign/browser/views.py @@ -66,6 +66,9 @@ def __call__(self): def available(self): return get_esign_registry_enabled() + def may_create_custom_session(self): + return api.user.has_permission(manage_session_perm, obj=self.context) + def render_table(self): table = SessionsTable(self.context, self, self.request, self.get_sessions()) table.update() diff --git a/src/imio/esign/configure.zcml b/src/imio/esign/configure.zcml index ba06a67..b26c209 100644 --- a/src/imio/esign/configure.zcml +++ b/src/imio/esign/configure.zcml @@ -48,6 +48,11 @@ provides="collective.compoundcriterion.interfaces.ICompoundCriterionFilter" name="files-belonging-to-a-given-session" /> + + + + + diff --git a/src/imio/esign/profiles/default/jsregistry.xml b/src/imio/esign/profiles/default/jsregistry.xml new file mode 100644 index 0000000..2670d61 --- /dev/null +++ b/src/imio/esign/profiles/default/jsregistry.xml @@ -0,0 +1,10 @@ + + + + diff --git a/src/imio/esign/tests/base.py b/src/imio/esign/tests/base.py index f634313..13a62bb 100644 --- a/src/imio/esign/tests/base.py +++ b/src/imio/esign/tests/base.py @@ -5,6 +5,8 @@ from plone.app.testing import setRoles from plone.app.testing import TEST_USER_ID from plone.app.testing import TEST_USER_NAME +from Products.statusmessages import STATUSMESSAGEKEY +from zope.annotation.interfaces import IAnnotations import os import unittest @@ -13,6 +15,13 @@ TESTS_DIR = os.path.dirname(__file__) +def clear_status_messages(request): + """Clear status messages from request annotations (needed after redirects since show() skips clearing on 3xx).""" + annotations = IAnnotations(request) + annotations[STATUSMESSAGEKEY] = None + request.response.expireCookie(STATUSMESSAGEKEY, path="/") + + class BaseEsignTest(unittest.TestCase): """Base class: shared layer, minimal setUp, and optionnal helpers.""" diff --git a/src/imio/esign/tests/test_browser_forms.py b/src/imio/esign/tests/test_browser_forms.py new file mode 100644 index 0000000..044dbc5 --- /dev/null +++ b/src/imio/esign/tests/test_browser_forms.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +"""Browser forms tests for this package.""" +from imio.esign.browser.forms import CreateCustomSessionForm +from imio.esign.tests.base import BaseEsignTest +from imio.esign.tests.base import clear_status_messages +from imio.esign.utils import get_session_annotation +from mock import Mock +from mock import patch +from plone import api +from Products.statusmessages.interfaces import IStatusMessage + + +HP_UID_PREFIX = "hp-uid-" + + +def _make_mock_hp(userid, fullname, position_title): + """Create a mock held_position with linked person.""" + mock_person = Mock() + mock_person.userid = userid + mock_person.get_title.return_value = fullname + mock_hp = Mock() + mock_hp.get_person.return_value = mock_person + mock_hp.get_full_title.return_value = position_title + return mock_hp + + +class TestCreateCustomSessionForm(BaseEsignTest): + """Tests for CreateCustomSessionForm.""" + + def setUp(self): + super(TestCreateCustomSessionForm, self).setUp() + api.user.create(email="signer1@sign.com", username="signer1", password="password1") # noqa: S106 + api.user.create(email="signer2@sign.com", username="signer2", password="password2") # noqa: S106 + self.form = CreateCustomSessionForm(self.portal, self.request) + self.hp1 = _make_mock_hp("signer1", "First Signer", u"First Signer, Agent (My Org)") + self.hp2 = _make_mock_hp("signer2", "Second Signer", u"Second Signer, Agent (My Org)") + self.hp1_uid = HP_UID_PREFIX + "signer1" + self.hp2_uid = HP_UID_PREFIX + "signer2" + self._hp_map = { + self.hp1_uid: self.hp1, + self.hp2_uid: self.hp2, + } + + def _uuid_to_object(self, uid, unrestricted=False): + return self._hp_map.get(uid) + + def _call_handleCreate(self, data, errors=()): + """Call handleCreate with mocked extractData and uuidToObject.""" + with patch.object(self.form, "extractData", return_value=(data, errors)): + with patch("imio.esign.browser.forms.uuidToObject", side_effect=self._uuid_to_object): + self.form.handleCreate(self.form, None) + + def test_extract_signer_info(self): + """HP UID → (userid, email, fullname, position); no person → None; unknown UID → None.""" + # --- valid held_position --- + with patch("imio.esign.browser.forms.uuidToObject", side_effect=self._uuid_to_object): + result = self.form.extract_signer_info(self.hp1_uid) + self.assertEqual(result, ("signer1", "signer1@sign.com", "First Signer", u"First Signer, Agent (My Org)")) + self.hp1.get_person.assert_called() + self.hp1.get_full_title.assert_called_with(first_index=1) + + # --- held_position with no person userid --- + mock_hp_no_user = _make_mock_hp("", "Nobody", u"Nobody (Org)") + with patch("imio.esign.browser.forms.uuidToObject", return_value=mock_hp_no_user): + result = self.form.extract_signer_info("hp-uid-nouser") + self.assertIsNone(result) + + # --- unknown UID --- + with patch("imio.esign.browser.forms.uuidToObject", return_value=None): + result = self.form.extract_signer_info("nonexistent-uid") + self.assertIsNone(result) + + def test_handleCreate(self): + """Validation errors → early return; no valid signers → warning; + valid signers → session created + success message + redirect; + mixed valid/invalid → only valid; seal and title passed through. + """ + expected_redirect = self.portal.absolute_url() + "/@@parapheo" + + # --- validation errors: early return, no session created --- + self._call_handleCreate(data={}, errors=("some error",)) + annot = get_session_annotation() + self.assertEqual(len(annot["sessions"]), 0) + messages = IStatusMessage(self.request).show() + self.assertEqual(len(messages), 0) + clear_status_messages(self.request) + + # --- no valid signers: warning message, no session --- + self._call_handleCreate( + data={"signers": {"nonexistent-uid"}, "seal": False, "title": u"Test"} + ) + messages = IStatusMessage(self.request).show() + self.assertEqual(len(messages), 1) + self.assertIn("No valid signers selected", messages[0].message) + self.assertEqual(messages[0].type, "warning") + self.assertEqual(len(annot["sessions"]), 0) + clear_status_messages(self.request) + + # --- empty signers set: same warning --- + self._call_handleCreate( + data={"signers": set(), "seal": False, "title": u"Test"} + ) + messages = IStatusMessage(self.request).show() + self.assertIn("No valid signers selected", messages[0].message) + self.assertEqual(len(annot["sessions"]), 0) + clear_status_messages(self.request) + + # --- valid single signer: session created, success message, redirect --- + self._call_handleCreate( + data={"signers": {self.hp1_uid}, "seal": False, "title": u"My Session"} + ) + self.assertEqual(len(annot["sessions"]), 1) + session = annot["sessions"][0] + self.assertEqual(len(session["signers"]), 1) + self.assertEqual(session["signers"][0]["userid"], "signer1") + self.assertEqual(session["signers"][0]["email"], "signer1@sign.com") + self.assertEqual(session["signers"][0]["position"], u"First Signer, Agent (My Org)") + self.assertFalse(session["seal"]) + self.assertEqual(session["title"], u"My Session") + messages = IStatusMessage(self.request).show() + self.assertIn("Custom session created successfully", messages[0].message) + self.assertEqual(messages[0].type, "info") + self.assertEqual(self.request.RESPONSE.getHeader("location"), expected_redirect) + clear_status_messages(self.request) + + # --- mixed valid/invalid signers: only valid ones in session --- + self._call_handleCreate( + data={"signers": {self.hp1_uid, "nonexistent-uid", self.hp2_uid}, "seal": False, "title": u"Mixed"} + ) + self.assertEqual(len(annot["sessions"]), 2) + session = annot["sessions"][1] + signer_userids = {s["userid"] for s in session["signers"]} + self.assertEqual(signer_userids, {"signer1", "signer2"}) + self.assertEqual(len(session["signers"]), 2) + clear_status_messages(self.request) + + # --- seal=True passed through --- + self._call_handleCreate( + data={"signers": {self.hp1_uid}, "seal": True, "title": u"Sealed"} + ) + self.assertEqual(len(annot["sessions"]), 3) + session = annot["sessions"][2] + self.assertTrue(session["seal"]) + clear_status_messages(self.request) + + def test_handleCancel(self): + """Cancel redirects to @@parapheo.""" + self.form.handleCancel(self.form, None) + expected = self.portal.absolute_url() + "/@@parapheo" + self.assertEqual(self.request.RESPONSE.getHeader("location"), expected) diff --git a/src/imio/esign/tests/test_browser_views.py b/src/imio/esign/tests/test_browser_views.py index d04e870..917dffe 100644 --- a/src/imio/esign/tests/test_browser_views.py +++ b/src/imio/esign/tests/test_browser_views.py @@ -12,6 +12,7 @@ from imio.esign.browser.views import SigningUsersCsv from imio.esign.config import set_esign_registry_signing_users_email_content from imio.esign.tests.base import BaseEsignTest +from imio.esign.tests.base import clear_status_messages from imio.esign.utils import add_files_to_session from imio.esign.utils import get_session_annotation from imio.pyutils.utils import shortuid_encode_id @@ -22,21 +23,12 @@ from plone.app.testing import setRoles from plone.app.testing import TEST_USER_ID from plone.testing import z2 -from Products.statusmessages import STATUSMESSAGEKEY from Products.statusmessages.interfaces import IStatusMessage -from zope.annotation.interfaces import IAnnotations import json import unittest -def _clear_status_messages(request): - """Clear status messages from request annotations (needed after redirects since show() skips clearing on 3xx).""" - annotations = IAnnotations(request) - annotations[STATUSMESSAGEKEY] = None - request.response.expireCookie(STATUSMESSAGEKEY, path="/") - - class TestSessionDeleteView(BaseEsignTest): """Tests for SessionDeleteView.""" @@ -116,12 +108,12 @@ def test_call(self): self.assertIn("No session ID provided!", messages[0].message) self.assertEqual(messages[0].type, "error") self.assertEqual("http://nohost/plone/folder0/@@parapheo", result) - _clear_status_messages(self.request) + clear_status_messages(self.request) self.request.form["session_id"] = str(self.session_id) def _run(return_value): - _clear_status_messages(self.request) + clear_status_messages(self.request) with patch("imio.esign.browser.views.create_external_session", return_value=return_value): return ExternalSessionCreateView(self.folder, self.request)() @@ -408,7 +400,7 @@ def test_download_csv(self): messages = IStatusMessage(self.request).show() self.assertIn("No users selected", messages[0].message) self.assertEqual(messages[0].type, "warning") - _clear_status_messages(self.request) + clear_status_messages(self.request) # --- valid selection --- # request.get() promotes form values to request.other; clear to prevent stale cache @@ -428,7 +420,7 @@ def test_send_emails(self): messages = IStatusMessage(self.request).show() self.assertIn("No users selected", messages[0].message) self.assertEqual(messages[0].type, "warning") - _clear_status_messages(self.request) + clear_status_messages(self.request) # request.get() promotes form values to request.other; clear to prevent stale cache self.request.other.pop("selected_users", None) @@ -440,7 +432,7 @@ def test_send_emails(self): messages = IStatusMessage(self.request).show() self.assertEqual(messages[0].message, u"Email content is not configured in the settings.") self.assertEqual(messages[0].type, "error") - _clear_status_messages(self.request) + clear_status_messages(self.request) # --- portal from email not configured --- set_esign_registry_signing_users_email_content(u"

Hello

") @@ -448,7 +440,7 @@ def test_send_emails(self): messages = IStatusMessage(self.request).show() self.assertEqual(messages[0].message, u"Portal from email is not configured.") self.assertEqual(messages[0].type, "error") - _clear_status_messages(self.request) + clear_status_messages(self.request) # --- success --- self.portal.manage_changeProperties({"email_from_address": "from@test.com"}) @@ -458,7 +450,7 @@ def test_send_emails(self): success_msgs = [m for m in messages if m.type == "info"] self.assertEqual(len(success_msgs), 1) self.assertIn("Emails sent successfully", success_msgs[0].message) - _clear_status_messages(self.request) + clear_status_messages(self.request) # --- user with no email address --- no_email_user = api.user.create( diff --git a/src/imio/esign/vocabularies.py b/src/imio/esign/vocabularies.py new file mode 100644 index 0000000..47fa091 --- /dev/null +++ b/src/imio/esign/vocabularies.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +from natsort import humansorted +from plone import api +from Products.CMFPlone.utils import safe_unicode +from zope.interface import implementer +from zope.schema.interfaces import IVocabularyFactory +from zope.schema.vocabulary import SimpleTerm +from zope.schema.vocabulary import SimpleVocabulary + + +@implementer(IVocabularyFactory) +class ActiveSignersVocabulary(object): + """Vocabulary of held_positions whose usages include 'signer'. + + To override in a downstream app, register your own IVocabularyFactory + under the same name in configure.zcml or overrides.zcml: + + + """ + + def __call__(self, context): + catalog = api.portal.get_tool("portal_catalog") + brains = catalog.unrestrictedSearchResults( + portal_type="held_position", + usages="signer", + ) + terms = [] + for brain in brains: + hp = brain._unrestrictedGetObject() + person = hp.get_person() + if person is None or not person.userid: + continue + user = api.user.get(userid=person.userid) + if user is None: + continue + email = safe_unicode(user.getProperty("email", u"")).strip() + if not email: + continue + uid = brain.UID + title = safe_unicode(hp.get_full_title(first_index=1)) + terms.append(SimpleTerm(value=uid, token=uid, title=title)) + terms = humansorted(terms, key=lambda t: t.title) + return SimpleVocabulary(terms) From 8d4b53117a6eeaab3a88ef07e78632e803836feb Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 20 May 2026 16:36:05 +0200 Subject: [PATCH 2/2] Added action to add a item to a specific session WIP --- CHANGES.rst | 2 + src/imio/esign/browser/actions.py | 72 +++++++++++ src/imio/esign/browser/configure.zcml | 9 ++ src/imio/esign/browser/static/esign.css | 24 ++++ src/imio/esign/browser/static/esign.js | 66 ++++++++++ src/imio/esign/browser/table.py | 56 +++++++++ .../templates/add_to_custom_esign_session.pt | 55 ++++++++ src/imio/esign/tests/test_actions.py | 117 ++++++++++++++++++ src/imio/esign/tests/test_table.py | 78 ++++++++++++ 9 files changed, 479 insertions(+) create mode 100644 src/imio/esign/browser/templates/add_to_custom_esign_session.pt create mode 100644 src/imio/esign/tests/test_table.py diff --git a/CHANGES.rst b/CHANGES.rst index 2fe84ff..5937eb3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -11,6 +11,8 @@ Changelog [chris-adam] - Added action to create a custom session. [chris-adam] +- Added action to add an item to a specific session. + [chris-adam] 1.0b8 (2026-05-08) ------------------ diff --git a/src/imio/esign/browser/actions.py b/src/imio/esign/browser/actions.py index ce86e4b..836ea6d 100644 --- a/src/imio/esign/browser/actions.py +++ b/src/imio/esign/browser/actions.py @@ -4,6 +4,7 @@ from imio.esign import _ from imio.esign.adapters import ISignable from imio.esign.audit import audit +from imio.esign.browser.table import FilteredSessionsTable from imio.esign.utils import add_files_to_session from imio.esign.utils import get_session_annotation from imio.esign.utils import get_sessions_for @@ -149,6 +150,77 @@ def available(self): return self.context.UID() in annot.get("uids", {}) +class AddToCustomEsignSessionView(BrowserView): + """Overlay form to add an item to a specific session.""" + + template = ViewPageTemplateFile("templates/add_to_custom_esign_session.pt") + _table = None + + def __call__(self): + self._table = FilteredSessionsTable(self.context, self, self.request) + self._table.update() + if self.request.method == "POST" and "form.buttons.submit" in self.request.form: + return self.handle_submit() + return self.template() + + def available(self): + annot = get_session_annotation() + return self.context.UID() not in annot.get("uids", {}) + + def render_table(self): + return self._table.render() + + def has_sessions(self): + return bool(self._table.rows) + + def handle_submit(self): + session_id_str = self.request.form.get("session_id") + if not session_id_str: + api.portal.show_message( + _(u"No session selected!"), request=self.request, type="warning" + ) + return self.template() + try: + session_id = int(session_id_str) + except (ValueError, TypeError): + api.portal.show_message( + _(u"Invalid session!"), request=self.request, type="error" + ) + return self.template() + annot = get_session_annotation() + session = annot["sessions"].get(session_id) + if not session or session.get("state") != "draft": + api.portal.show_message( + _(u"Session not found or no longer draft!"), + request=self.request, + type="error", + ) + return self.template() + file_uid = self.context.UID() + old_session_id = annot.get("uids", {}).get(file_uid) + if old_session_id is not None and old_session_id != session_id: + remove_files_from_session([file_uid]) + signers = [ + (s["userid"], s["email"], s["fullname"], s["position"]) + for s in session.get("signers", []) + ] + add_files_to_session( + signers=signers, + files_uids=[file_uid], + session_id=session_id, + seal=session.get("seal"), + title=session.get("title", ""), + ) + api.portal.show_message( + _(u"File added to session!"), request=self.request, type="info" + ) + self.request.RESPONSE.redirect(self.context.absolute_url()) + + @property + def portal_url(self): + return api.portal.get().absolute_url() + + class SessionAnnotationInfoView(BrowserView): """Admin-only view displaying imio.esign session annotations for a specific context item.""" diff --git a/src/imio/esign/browser/configure.zcml b/src/imio/esign/browser/configure.zcml index 164f023..33fb77a 100644 --- a/src/imio/esign/browser/configure.zcml +++ b/src/imio/esign/browser/configure.zcml @@ -112,6 +112,15 @@ allowed_attributes="available" /> + + *', + formselector: '#add-to-custom-esign-session-form', + closeselector: '[name="form.buttons.cancel"]', + noform: function(el, pbo) { + window.location.reload(); + } + }); + } + }); + } + initCustomSessionOverlays(); + $(document).ajaxComplete(initCustomSessionOverlays); + + // Enable submit button when a radio session is selected + function initSubmitToggle(container) { + var $container = $(container || document); + $container.find('#add-to-custom-esign-session-form input[name="session_id"]').on('change', function() { + $(this).closest('form').find('#esign-submit-btn').prop('disabled', false); + }); + } + $(document).bind('loadInsideOverlay', function(e, el) { initSubmitToggle(el); }); + + // After the choose-session overlay loads, bind the "Create new session" + // link so it opens @@create-custom-session in a nested prepOverlay. + // On successful creation, the outer overlay content is refetched and + // the newest session is auto-selected. + $(document).bind('loadInsideOverlay', function(e, el) { + var $el = $(el); + var $form = $el.find('#add-to-custom-esign-session-form'); + if (!$form.length) return; + + var sessionListUrl = $form.attr('action'); + var $pbAjax = $el.hasClass('pb-ajax') ? $el : $el.find('.pb-ajax'); + if (!$pbAjax.length) $pbAjax = $el; + + $el.find('a.esign-create-custom-session-from-overlay').each(function() { + if ($(this).data('pbo') !== undefined) return; + $(this).prepOverlay({ + subtype: 'ajax', + filter: '#content>*', + formselector: '#form', + closeselector: '[name="form.buttons.cancel"]', + noform: function(innerEl, innerPbo) { + // Session created. Refetch the session list into the + // outer overlay and auto-select the newest session. + $pbAjax.load( + sessionListUrl + '?ajax_load=' + (new Date().getTime()) + ' #content>*', + function() { + $pbAjax.find('input[name="session_id"]:first').prop('checked', true); + $pbAjax.find('#esign-submit-btn').prop('disabled', false); + // Re-initialise nested handlers inside the refreshed content + $(document).trigger('loadInsideOverlay', [el]); + } + ); + return 'close'; + } + }); + }); + }); }); diff --git a/src/imio/esign/browser/table.py b/src/imio/esign/browser/table.py index be4f42f..0e2c3db 100644 --- a/src/imio/esign/browser/table.py +++ b/src/imio/esign/browser/table.py @@ -6,6 +6,7 @@ from imio.esign.config import get_esign_registry_max_session_size from imio.esign.config import get_esign_registry_seal_code from imio.esign.config import get_esign_registry_seal_email +from imio.esign.utils import get_session_annotation from imio.esign.utils import get_state_description from imio.helpers.security import check_zope_admin from imio.pyutils.utils import safe_encode @@ -273,3 +274,58 @@ def setUpColumns(self): seal_col = SealColumn(ctx, req, tbl) columns.insert(4, seal_col) return columns + + +class RadioColumn(Column): + header = u"" + weight = 5 + cssClasses = {"th": "th_header_sessions_radio nosort", + "td": "radio-column"} + + def renderCell(self, item): + sid = item.get("id") + return u''.format(sid) + + +class FilteredSessionsTable(Table): + cssClassEven = "even" + cssClassOdd = "odd" + cssClasses = {"table": "listing sessions-table width-full"} + sortOn = None + results = [] + + def __init__(self, context, view, request): + super(FilteredSessionsTable, self).__init__(context, request) + self.view = view + self.portal_url = api.portal.get().absolute_url() + self._items = None + + def filter_session(self, session): + return session.get("state") == "draft" + + @property + def values(self): + if self._items is not None: + return self._items + annot = get_session_annotation() + result = [] + for session_id, session in sorted(annot.get("sessions", {}).items(), reverse=True): + if not self.filter_session(session): + continue + s = dict(session) + s["id"] = session_id + result.append(s) + return result + + def setUpColumns(self): + ctx, req, tbl = self.context, self.request, self + columns = [ + RadioColumn(ctx, req, tbl), + IdColumn(ctx, req, tbl), + TitleColumn(ctx, req, tbl), + SignersColumn(ctx, req, tbl), + FilesColumn(ctx, req, tbl), + ] + if get_esign_registry_seal_code() and get_esign_registry_seal_email(): + columns.append(SealColumn(ctx, req, tbl)) + return columns diff --git a/src/imio/esign/browser/templates/add_to_custom_esign_session.pt b/src/imio/esign/browser/templates/add_to_custom_esign_session.pt new file mode 100644 index 0000000..9f26559 --- /dev/null +++ b/src/imio/esign/browser/templates/add_to_custom_esign_session.pt @@ -0,0 +1,55 @@ + + + + +

+ Choose an e-sign session +

+
+ + + + + +
+ + +
+ + +

+ No draft sessions available. +

+
+ +
+ + +
+ +
+ + + + + + + diff --git a/src/imio/esign/tests/test_actions.py b/src/imio/esign/tests/test_actions.py index 71fb416..8c75d3e 100644 --- a/src/imio/esign/tests/test_actions.py +++ b/src/imio/esign/tests/test_actions.py @@ -1,16 +1,20 @@ # -*- coding: utf-8 -*- """actions tests for this package.""" from AccessControl import Unauthorized +from imio.esign.browser.actions import AddToCustomEsignSessionView from imio.esign.browser.actions import RemoveFromSessionView from imio.esign.browser.actions import RemoveItemFromSessionView from imio.esign.browser.actions import SessionAnnotationInfoView from imio.esign.tests.base import BaseEsignTest +from imio.esign.tests.base import clear_status_messages from imio.esign.utils import add_files_to_session +from imio.esign.utils import create_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 Products.statusmessages.interfaces import IStatusMessage try: @@ -263,3 +267,116 @@ def test_esign_sessions(self): repr(esign_session[1]["last_update"]), ), ) + + +class TestAddToCustomEsignSessionView(BaseEsignTest): + """Tests for AddToCustomEsignSessionView browser view.""" + + def setUp(self): + super(TestAddToCustomEsignSessionView, self).setUp() + api.user.create(email="user1@sign.com", username="user1", password="password1") # noqa: S106 + self.annexes = [self.portal["folder0"]["annex{}".format(i)] for i in (0, 2, 4)] + self.signers = [("user1", "user1@sign.com", "User 1", "Position 1")] + self.view = AddToCustomEsignSessionView(self.annexes[0], self.request) + + def test_available(self): + """True when annex not in any session; False once added.""" + self.assertTrue(self.view.available()) + add_files_to_session(self.signers, [self.annexes[0].UID()]) + self.assertFalse(self.view.available()) + + def test_call(self): + """POST dispatches to handle_submit.""" + self.request.form.clear() + sid, _session = create_session(self.signers, title="") + self.request.method = "POST" + self.request.form["form.buttons.submit"] = "Add to session" + self.request.form["session_id"] = str(sid) + view = AddToCustomEsignSessionView(self.annexes[0], self.request) + view() + annot = get_session_annotation() + self.assertEqual(annot["uids"][self.annexes[0].UID()], sid) + + def test_has_sessions(self): + """False with no draft sessions; True once a draft session exists.""" + self.view() + self.assertFalse(self.view.has_sessions()) + create_session(self.signers, title="") + view = AddToCustomEsignSessionView(self.annexes[0], self.request) + view() + self.assertTrue(view.has_sessions()) + + def test_handle_submit(self): + """All handle_submit branches: missing/invalid/not-found/not-draft id; success; move; same session.""" + annot = get_session_annotation() + + # --- no session_id → warning --- + create_session(self.signers, title="") + self.request.method = "POST" + self.request.form["form.buttons.submit"] = "Add to session" + result = self.view() + self.assertIn("No session selected!", result) + self.assertNotIn(self.annexes[0].UID(), annot["uids"]) + + # --- invalid session_id → error --- + self.request.form["session_id"] = "abc" + view = AddToCustomEsignSessionView(self.annexes[0], self.request) + result = view() + self.assertIn("Invalid session!", result) + + # --- nonexistent session_id → error --- + self.request.form["session_id"] = "999" + view = AddToCustomEsignSessionView(self.annexes[0], self.request) + result = view() + self.assertIn("Session not found or no longer draft!", result) + + # --- session not draft → error --- + sid_sent, session_sent = create_session(self.signers, title="") + session_sent["state"] = "sent" + self.request.form["session_id"] = str(sid_sent) + view = AddToCustomEsignSessionView(self.annexes[0], self.request) + result = view() + self.assertIn("Session not found or no longer draft!", result) + + # --- success: file added, info message, redirect --- + sid_draft = [s for s in annot["sessions"] if annot["sessions"][s].get("state") == "draft"][0] + self.request.form.clear() + self.request.method = "POST" + self.request.form["form.buttons.submit"] = "Add to session" + self.request.form["session_id"] = str(sid_draft) + view = AddToCustomEsignSessionView(self.annexes[0], self.request) + view() + self.assertEqual(annot["uids"][self.annexes[0].UID()], sid_draft) + file_uids = [f["uid"] for f in annot["sessions"][sid_draft]["files"]] + self.assertIn(self.annexes[0].UID(), file_uids) + messages = IStatusMessage(self.request).show() + self.assertIn("File added to session!", messages[0].message) + self.assertEqual(messages[0].type, u"info") + self.assertEqual( + self.request.RESPONSE.getHeader("location"), + self.annexes[0].absolute_url(), + ) + clear_status_messages(self.request) + + # --- move file from one session to another --- + sid_other, _session_other = create_session(self.signers, title="") + self.request.form.clear() + self.request.method = "POST" + self.request.form["form.buttons.submit"] = "Add to session" + self.request.form["session_id"] = str(sid_other) + view = AddToCustomEsignSessionView(self.annexes[0], self.request) + view() + self.assertEqual(annot["uids"][self.annexes[0].UID()], sid_other) + file_uids_other = [f["uid"] for f in annot["sessions"][sid_other]["files"]] + self.assertIn(self.annexes[0].UID(), file_uids_other) + clear_status_messages(self.request) + + # --- same session: idempotent re-add --- + self.request.form.clear() + self.request.method = "POST" + self.request.form["form.buttons.submit"] = "Add to session" + self.request.form["session_id"] = str(sid_other) + view = AddToCustomEsignSessionView(self.annexes[0], self.request) + view() + self.assertEqual(annot["uids"][self.annexes[0].UID()], sid_other) + self.assertIn(sid_other, annot["sessions"]) diff --git a/src/imio/esign/tests/test_table.py b/src/imio/esign/tests/test_table.py new file mode 100644 index 0000000..4e84f32 --- /dev/null +++ b/src/imio/esign/tests/test_table.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +"""table tests for this package.""" +from imio.esign.browser.table import FilteredSessionsTable +from imio.esign.browser.table import SealColumn +from imio.esign.config import set_esign_registry_seal_code +from imio.esign.config import set_esign_registry_seal_email +from imio.esign.tests.base import BaseEsignTest +from imio.esign.utils import create_session +from plone import api + + +class TestFilteredSessionsTable(BaseEsignTest): + """Tests for FilteredSessionsTable.""" + + def setUp(self): + super(TestFilteredSessionsTable, self).setUp() + api.user.create(email="user1@sign.com", username="user1", password="password1") # noqa: S106 + self.signers = [("user1", "user1@sign.com", "User 1", "Position 1")] + self.folder = self.portal["folder0"] + self.mock_view = type("MockView", (), {})() + + def test_filter_session(self): + """Returns True only for sessions with state 'draft'.""" + table = FilteredSessionsTable(self.folder, self.mock_view, self.request) + self.assertTrue(table.filter_session({"state": "draft"})) + self.assertFalse(table.filter_session({"state": "sent"})) + self.assertFalse(table.filter_session({"state": "finalized"})) + self.assertFalse(table.filter_session({})) + + def test_values(self): + """Loads from annotation filtered to draft and reverse-sorted otherwise.""" + # --- empty annotation: returns empty list --- + table = FilteredSessionsTable(self.folder, self.mock_view, self.request) + self.assertEqual(table.values, []) + + # --- from annotation: filters to draft, reverse-sorted by id --- + sid_a, _session = create_session(self.signers) + sid_b, session_b = create_session(self.signers) + table = FilteredSessionsTable(self.folder, self.mock_view, self.request) + values = table.values + self.assertEqual(len(values), 2) + self.assertEqual(values[0]["id"], sid_b) + self.assertEqual(values[1]["id"], sid_a) + + # --- non-draft sessions are excluded --- + session_b["state"] = "sent" + table = FilteredSessionsTable(self.folder, self.mock_view, self.request) + values = table.values + self.assertEqual(len(values), 1) + self.assertEqual(values[0]["id"], sid_a) + + def test_setUpColumns(self): + """Returns 5 columns without seal; 6 with seal config set.""" + table = FilteredSessionsTable(self.folder, self.mock_view, self.request) + columns = table.setUpColumns() + self.assertEqual(len(columns), 5) + self.assertFalse(any(isinstance(column, SealColumn) for column in columns)) + + self.addCleanup(set_esign_registry_seal_code, u"") + self.addCleanup(set_esign_registry_seal_email, u"") + set_esign_registry_seal_code(u"PADES_SEAL") + set_esign_registry_seal_email(u"seal@example.com") + columns = table.setUpColumns() + self.assertEqual(len(columns), 6) + self.assertTrue(any(isinstance(column, SealColumn) for column in columns)) + + def test_update(self): + """Populates rows from draft sessions; empty when none exist.""" + # --- no draft sessions → empty rows --- + table = FilteredSessionsTable(self.folder, self.mock_view, self.request) + table.update() + self.assertEqual(len(table.rows), 0) + + # --- with a draft session → one row --- + create_session(self.signers) + table = FilteredSessionsTable(self.folder, self.mock_view, self.request) + table.update() + self.assertEqual(len(table.rows), 1)