Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -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)
------------------
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"eea.facetednavigation",
"imio.fpaudit",
"imio.helpers>1.3.10",
"natsort",
"imio.prettylink",
"imio.pyutils",
"plone.api>=1.8.4",
Expand Down
8 changes: 8 additions & 0 deletions src/imio/esign/browser/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,14 @@
permission="cmf.ManagePortal"
/>

<browser:page
name="create-custom-session"
for="Products.CMFPlone.interfaces.IPloneSiteRoot"
class=".forms.CreateCustomSessionFormView"
permission="imio.esign.ManageSessions"
i18n:domain="imio.esign"
/>

<browser:page
name="signing-users-csv"
for="Products.CMFPlone.interfaces.IPloneSiteRoot"
Expand Down
151 changes: 151 additions & 0 deletions src/imio/esign/browser/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# -*- coding: utf-8 -*-
from imio.esign import _
from imio.esign.config import get_esign_registry_seal_code
from imio.esign.utils import create_session
from imio.helpers.content import uuidToObject
from plone import api
from plone.autoform import directives
from plone.autoform.form import AutoExtensibleForm
from plone.z3cform.layout import wrap_form
from z3c.form import button
from z3c.form import form
from z3c.form.browser.checkbox import CheckBoxFieldWidget
from zope import schema
from zope.component import queryUtility
from zope.interface import implementer
from zope.interface import Interface
from zope.schema.interfaces import IContextSourceBinder
from zope.schema.interfaces import IVocabularyFactory
from zope.schema.vocabulary import SimpleVocabulary


@implementer(IContextSourceBinder)
class SignersSourceBinder(object):
"""Source binder that delegates to the named vocabulary."""

def __call__(self, context):
factory = queryUtility(
IVocabularyFactory, name=u"imio.esign.ActiveSignersVocabulary"
)
if factory is not None:
return factory(context)
return SimpleVocabulary([])


class ICreateCustomSession(Interface):

title = schema.TextLine(
title=_(u"Session title"),
required=False,
)

signers = schema.Set(
title=_(u"Signers"),
required=True,
value_type=schema.Choice(
source=SignersSourceBinder(),
),
)
directives.widget("signers", CheckBoxFieldWidget)

seal = schema.Bool(
title=_(u"Seal"),
required=False,
default=False,
)


class CreateCustomSessionForm(AutoExtensibleForm, form.Form):

schema = ICreateCustomSession
ignoreContext = True
label = _(u"Create custom session")
css_class = u"create-custom-session"

def get_default_seal(self):
"""Return the default value for the seal field.
Override in a subclass to change the default.
"""
return False

def get_default_title(self):
"""Return the default value for the title field.
Override in a subclass to change the default.
"""
return _(u"Custom session")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

A voir si on veut mettre un titre par défaut particulier


def extract_signer_info(self, value):
"""Extract signer info from a held_position UID.

Returns a (userid, email, fullname, position) tuple, or None
if the held_position does not exist or has no linked user.
"""
hp = uuidToObject(value, unrestricted=True)
if hp is None:
return None
person = hp.get_person()
if person is None or not person.userid:
return None
user = api.user.get(userid=person.userid)
if user is None:
return None
email = user.getProperty("email", "")
fullname = person.get_title(include_person_title=False)
position = hp.get_full_title(first_index=1)
return (person.userid, email, fullname, position)
Comment on lines +92 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a defensive empty-email guard in signer extraction.

On Line 92, email can still be empty in edge cases (stale/tampered submit), and currently gets passed to create_session. Skip such signer entries to keep payload integrity.

💡 Proposed fix
-        email = user.getProperty("email", "")
+        email = (user.getProperty("email", u"") or u"").strip()
+        if not email:
+            return None
         fullname = person.get_title(include_person_title=False)
         position = hp.get_full_title(first_index=1)
         return (person.userid, email, fullname, position)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/imio/esign/browser/forms.py` around lines 92 - 95, The signer-extraction
code that sets email = user.getProperty(...) can produce empty emails; add a
defensive guard after computing email: if not email, log or ignore and return
None (or otherwise skip this signer) so empty-email entries are not included in
the signers payload passed to create_session. Update the caller that collects
signers to filter out falsy/None results before building the list given to
create_session so only signers with non-empty email, e.g. from person.get_title
and hp.get_full_title, are included.


def updateFields(self):
super(CreateCustomSessionForm, self).updateFields()
if not get_esign_registry_seal_code():
self.fields = self.fields.omit("seal")

def updateWidgets(self):
super(CreateCustomSessionForm, self).updateWidgets()
if not self.widgets["title"].value:
self.widgets["title"].value = self.get_default_title()
if "seal" in self.widgets:
if self.get_default_seal():
Comment thread
chris-adam marked this conversation as resolved.
self.widgets["seal"].value = ("selected",)

@button.buttonAndHandler(_(u"Create"), name="create")
def handleCreate(self, action):
data, errors = self.extractData()
if errors:
return

signers = []
for value in data.get("signers", []):
info = self.extract_signer_info(value)
if info is not None:
signers.append(info)

if not signers:
api.portal.show_message(
_(u"No valid signers selected!"),
request=self.request,
type="warning",
)
return

seal = data.get("seal", False)
title = data.get("title") or u""

create_session(signers=signers, seal=seal, title=title)

api.portal.show_message(
_(u"Custom session created successfully!"),
request=self.request,
type="info",
)
self.request.RESPONSE.redirect(
api.portal.get().absolute_url() + "/@@parapheo"
)

@button.buttonAndHandler(_(u"Cancel"), name="cancel")
def handleCancel(self, action):
self.request.RESPONSE.redirect(
api.portal.get().absolute_url() + "/@@parapheo"
)


CreateCustomSessionFormView = wrap_form(CreateCustomSessionForm)
17 changes: 17 additions & 0 deletions src/imio/esign/browser/static/esign.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
.create-custom-session .fieldset-level-1 {
margin-bottom: 0;
}

.create-custom-session #formfield-form-widgets-signers {
max-height: 300px;
overflow-y: auto;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 1.5em;
}

.create-custom-session #formfield-form-widgets-seal {
padding-top: 1em;
border-top: 1px solid #ccc;
}
11 changes: 11 additions & 0 deletions src/imio/esign/browser/static/esign.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
jQuery(function($) {
$('a.esign-create-custom-session').prepOverlay({
subtype: 'ajax',
filter: '#content>*',
formselector: '#form',
closeselector: '[name="form.buttons.cancel"]',
noform: function(el, pbo) {
window.location.reload();
}
});
});
5 changes: 5 additions & 0 deletions src/imio/esign/browser/templates/sessions.pt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@
</style>
<div tal:replace="structure view/render_table"></div>
<div style="text-align: center; margin-top: 8px;">
<a class="esign-create-custom-session"
tal:attributes="href string:${context/absolute_url}/@@create-custom-session"
tal:condition="python: view.may_create_custom_session()"
i18n:translate="" i18n:domain="imio.esign">Create custom session</a>
<span tal:condition="python: view.may_create_custom_session()"> &#9679; </span>
<a tal:attributes="href view/get_parapheo_link" target="_blank">
<span i18n:translate="" i18n:domain="imio.esign">Open eSign platform</span>
<i class="fas fa-external-link-alt"></i>
Expand Down
3 changes: 3 additions & 0 deletions src/imio/esign/browser/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions src/imio/esign/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@
provides="collective.compoundcriterion.interfaces.ICompoundCriterionFilter"
name="files-belonging-to-a-given-session" />

<utility
name="imio.esign.ActiveSignersVocabulary"
factory=".vocabularies.ActiveSignersVocabulary"
provides="zope.schema.interfaces.IVocabularyFactory" />

<genericsetup:upgradeSteps
profile="imio.esign:default"
source="1000"
Expand Down
10 changes: 10 additions & 0 deletions src/imio/esign/profiles/default/cssregistry.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0"?>
<object name="portal_css">
<stylesheet
id="++resource++imio.esign/esign.css"
cacheable="True"
compression="safe"
cookable="True"
enabled="True"
rendering="link" />
</object>
10 changes: 10 additions & 0 deletions src/imio/esign/profiles/default/jsregistry.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0"?>
<object name="portal_javascripts">
<javascript
id="++resource++imio.esign/esign.js"
cacheable="True"
compression="safe"
cookable="True"
enabled="True"
inline="False" />
</object>
9 changes: 9 additions & 0 deletions src/imio/esign/tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""

Expand Down
Loading
Loading