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
4 changes: 4 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ Changelog
[chris-adam, sgeulette]
- Added "Open Paraphéo" button on sessions listing view.
[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)
------------------
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
72 changes: 72 additions & 0 deletions src/imio/esign/browser/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""

Expand Down
17 changes: 17 additions & 0 deletions src/imio/esign/browser/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,30 @@
allowed_attributes="available"
/>

<browser:page
name="add-to-custom-esign-session"
for="*"
class=".actions.AddToCustomEsignSessionView"
permission="imio.esign.ManageSessions"
i18n:domain="imio.esign"
allowed_attributes="available"
/>

<browser:page
for="*"
name="session-annotation-info"
class=".actions.SessionAnnotationInfoView"
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)
41 changes: 41 additions & 0 deletions src/imio/esign/browser/static/esign.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/* Add-to-custom-esign-session overlay table */
#add-to-custom-esign-session-form .sessions-table {
table-layout: fixed;
width: 100%;
overflow-wrap: break-word;
}

#add-to-custom-esign-session-form .th_header_sessions_radio { width: 4%; }
#add-to-custom-esign-session-form .th_header_sessions_id { width: 6%; }
#add-to-custom-esign-session-form .th_header_sessions_title { width: 25%; }
#add-to-custom-esign-session-form .th_header_sessions_signers { width: 30%; }
#add-to-custom-esign-session-form .th_header_sessions_seal { width: 7%; }
#add-to-custom-esign-session-form .th_header_sessions_documents { width: 28%; }

#add-to-custom-esign-session-form .signers-column ol {
margin: 0;
padding-left: 1.2em;
}

#add-to-custom-esign-session-form .documents-column .collapsible {
white-space: normal;
}

/* Create-custom-session form */
.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;
}
Loading
Loading