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
3 changes: 2 additions & 1 deletion CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
Changelog
=========


1.0b11 (unreleased)
-------------------

Expand All @@ -11,6 +10,8 @@ Changelog
[chris-adam]
- When a session status is received as completed, mark each signer as signed.
[sgeulette]
- Added action RecreateSession (PARAF-420).
[chris-adam]

1.0b10 (2026-06-18)
-------------------
Expand Down
177 changes: 177 additions & 0 deletions src/imio/esign/browser/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
from AccessControl import Unauthorized
from html import escape
from imio.esign import _
from imio.esign import manage_session_perm
from imio.esign.adapters import ISignable
from imio.esign.audit import audit
from imio.esign.browser.views import SessionFilesMixin
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 imio.esign.utils import get_sessions_for
from imio.esign.utils import persistent_to_native
Expand All @@ -17,7 +20,9 @@
from Products.Five import BrowserView
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
from six import string_types
from zope.i18n import translate

import json
import pprint
import re

Expand Down Expand Up @@ -228,3 +233,175 @@ def esign_session_html(self, session_data):
def available(self):
"""Defines if the action is available or not."""
return check_zope_admin()


class _RecreateSessionMixin(object):
"""Shared permission check and request validation for the recreate views."""

NON_RECREATABLE_STATES = ("draft", "returned", "finalized")

def may_recreate_session(self, state=None):
"""Whether the current user may recreate a session.

When ``state`` is given, a session in a non-recreatable state is refused
regardless of permission (used by the table column to show the button).
"""
if state in self.NON_RECREATABLE_STATES:
return False
return api.user.has_permission(manage_session_perm, obj=self.context)

def _resolve_session(self):
"""Validate ``esign_session_id`` and return ``(session_id, session, error)``.

``error`` is ``None`` for a recreatable (non-draft) session, otherwise a
``(message, message_type)`` pair explaining why the request was rejected.
"""
session_id = self.request.form.get("esign_session_id")
if not session_id:
return None, None, (_("No session ID provided!"), "error")
if not session_id.isdigit():
return None, None, (_("Invalid session ID!"), "error")
session_id = int(session_id)
session = get_session_annotation()["sessions"].get(session_id)
if session is None:
return None, None, (_("Session not found!"), "error")
if session["state"] == "draft":
return None, None, (_("Cannot recreate a draft session!"), "warning")
if session["state"] in self.NON_RECREATABLE_STATES:
return None, None, (_("Cannot recreate a finished session!"), "warning")
return session_id, session, None


class RecreateSessionView(_RecreateSessionMixin, BrowserView):
"""Admin view to recreate a fresh draft session from an existing non-draft session."""

_new_session_id = None

def _redirect(self, msg, type="error"):
"""Flash ``msg`` and return the parapheo redirect URL."""
api.portal.show_message(msg, request=self.request, type=type)
return self.context.absolute_url() + "/@@parapheo"

def get_new_session_title(self, old, old_session_id):
"""Title for the recreated session. Override in consuming apps.

:param old: the source session dict being recreated
:param old_session_id: the source session id
:return: a title string
"""
return u""

def __call__(self):
if not self.may_recreate_session():
raise Unauthorized
session_id, old, error = self._resolve_session()
if error:
return self._redirect(*error)
annot = get_session_annotation()
# Extract all data from old session before deleting it
title = self.get_new_session_title(old, session_id)
signers = [(s["userid"], s["email"], s["fullname"], s["position"]) for s in old["signers"]]
files_uids = [f["uid"] for f in old["files"]]
raw_selection = self.request.form.get("file_uids")
if raw_selection is not None:
try:
selected = set(json.loads(raw_selection or "[]"))
except (ValueError, TypeError):
selected = set()
files_uids = [uid for uid in files_uids if uid in selected]
if not files_uids:
return self._redirect(_("No file selected!"), "warning")
seal = old.get("seal")
acroform = old.get("acroform", True)
discriminators = old.get("discriminators", ())
watchers = list(old.get("watchers", []))
# Remove selected files from the old session
remove_files_from_session(files_uids)
# Create new draft session (call create_session directly to bypass discriminate_sessions)
new_id, _new_session = create_session(
signers=signers,
seal=seal,
acroform=acroform,
title=title,
annot=annot,
Comment thread
chris-adam marked this conversation as resolved.
discriminators=discriminators,
watchers=watchers,
create_session_custom_data={"recreated_from": session_id},
)
Comment thread
chris-adam marked this conversation as resolved.
add_files_to_session(
signers=signers,
files_uids=files_uids,
session_id=new_id,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
self._new_session_id = new_id
audit(
"recreate_session",
"old_session={} new_session={} files={}".format(session_id, new_id, len(files_uids)),
)
return self._redirect(
_("New session ${nid} created from session ${oid}", mapping={"nid": new_id, "oid": session_id}),
"info",
)


class RecreateSessionFormView(_RecreateSessionMixin, SessionFilesMixin, BrowserView):
"""Overlay form to choose which files to include when recreating a session."""

index = ViewPageTemplateFile("templates/recreate_session_form.pt")
session_id = None
_session = None

def __call__(self):
if not self.may_recreate_session():
raise Unauthorized
session_id, session, error = self._resolve_session()
if error:
return self._error(error[0])
self.session_id = session_id
self._session = session
return self.index()

def _error(self, msg):
"""Render a standalone error message inside the overlay."""
return u'<div class="portalMessage error">{}</div>'.format(translate(msg, context=self.request))

def files(self):
"""The (context, file) object pairs of the session"""
return self.resolve_session_files(self._session)

def no_file_msg(self):
"""Translated alert shown when the user submits with no file selected."""
return translate(_("Please select at least one file."), context=self.request)

def recreate_onclick(self):
"""JS run by the Recreate button: collect checked files then reload.

Built here (not in the template) so the many semicolons don't collide
with the ``tal:attributes`` separator.
"""
js = (
"var u=Array.prototype.map.call("
"this.closest('.recreate-session-form').querySelectorAll('.recreate-file-cb:checked'),"
"function(c){return c.value;});"
"if(!u.length){alert('%(msg)s');return;}"
"callViewAndReload('%(base)s','@@esign-session-recreate',"
"{'esign_session_id':'%(sid)s','file_uids':JSON.stringify(u)});"
) % {
"msg": self.no_file_msg().replace(u"'", u"\\'"),
"base": self.context.absolute_url(),
"sid": self.session_id,
}
return js

def refused_reason(self):
"""Refusal reason for a refused session, else an empty string.

The reason is stored by the external feedback service in the "returns"
list, on the code 52 (document_declined) entry, as ``value["reason"]``.
"""
if not self._session or self._session.get("state") != "refused":
return u""
for entry in reversed(list(self._session.get("returns", []))):
if entry and entry[0] == 52 and isinstance(entry[2], dict):
return entry[2].get("reason", u"") or u""
return u""
25 changes: 25 additions & 0 deletions src/imio/esign/browser/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,24 @@
i18n:domain="imio.esign"
/>

<browser:page
name="esign-session-recreate"
for="Products.CMFPlone.interfaces.IPloneSiteRoot"
class=".actions.RecreateSessionView"
permission="zope2.View"
allowed_attributes="may_recreate_session"
i18n:domain="imio.esign"
/>

<browser:page
name="esign-session-recreate-form"
for="Products.CMFPlone.interfaces.IPloneSiteRoot"
class=".actions.RecreateSessionFormView"
permission="zope2.View"
allowed_attributes="may_recreate_session files refused_reason session_id no_file_msg recreate_onclick"
i18n:domain="imio.esign"
/>

<browser:page
name="external-esign-session-create"
for="Products.CMFPlone.interfaces.IPloneSiteRoot"
Expand Down Expand Up @@ -143,4 +161,11 @@
template="templates/macros.pt"
permission="zope2.View" />

<browser:page
name="esign-files-list-macro"
for="*"
class=".views.EsignMacros"
template="templates/files_list.pt"
permission="zope2.View" />

</configure>
13 changes: 13 additions & 0 deletions src/imio/esign/browser/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,19 @@ def renderCell(self, item):
session_id=session_id,
send=translate(_("Create external session"), context=self.request),
)
if getMultiAdapter((portal, self.request),
name="esign-session-recreate-form").may_recreate_session(item.get("state")):
recreate_title = translate(_("Recreate session"), context=self.request)
admin_buttons += u"""
<a class="link-overlay-info" title="{recreate_title}" target="_blank"
href="{sessions_url}/@@esign-session-recreate-form?esign_session_id={session_id}">
<i class="fa fa-redo" style="cursor:pointer"></i>
</a>
""".format(
recreate_title=recreate_title,
sessions_url=sessions_url,
session_id=session_id,
)
if check_zope_admin():
admin_buttons += u"""
<a class="link-overlay-info" href="{sessions_url}/@@session-annotation-info?session_id={session_id}"
Expand Down
20 changes: 20 additions & 0 deletions src/imio/esign/browser/templates/files_list.pt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<ol metal:define-macro="files_list" i18n:domain="imio.esign"
tal:condition="files"
tal:define="with_checkbox with_checkbox|python:False;
with_info_link with_info_link|python:True">
<tal:loop repeat="file files">
<li tal:define="oddrow repeat/file/odd;
classOddEven python: oddrow and 'even' or 'odd';"
tal:attributes="class classOddEven">
<input tal:condition="with_checkbox" type="checkbox" class="recreate-file-cb"
checked="checked" tal:attributes="value python:file[1].UID()" />
<a tal:condition="python: with_info_link and context.unrestrictedTraverse('@@session-annotation-info').available()"
tal:attributes="href python:file[0].absolute_url() + '/@@session-annotation-info?context_uid=' + file[0].UID()"
target="_blank"
class="link-overlay-info">
<span class="fa fa-info-circle" title="Annotation info"></span>
</a>
<tal:link tal:replace="structure python:view.get_file_link(file[0], file[1])">link</tal:link>
</li>
</tal:loop>
</ol>
38 changes: 38 additions & 0 deletions src/imio/esign/browser/templates/recreate_session_form.pt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<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"
i18n:domain="imio.esign">
<body>
<div id="content">
<div id="content-core" class="recreate-session-form"
tal:define="reason view/refused_reason">

<h1 class="documentFirstHeading" i18n:translate="">Recreate session</h1>

<dl class="portalMessage warning" tal:condition="reason">
<dt i18n:translate="">Refusal reason</dt>
<dd tal:content="reason">the refusal reason</dd>
</dl>

<form tal:define="files view/files;
with_checkbox python:True;
with_info_link python:False;
template python: context.unrestrictedTraverse('@@esign-files-list-macro').index">
<fieldset>
<legend i18n:translate="">Select the files to include in the new session</legend>
<metal:list use-macro="python: template.macros['files_list']" />
</fieldset>

<div class="formControls">
<input type="button" class="context"
i18n:attributes="value" value="Recreate session"
tal:attributes="onclick view/recreate_onclick" />
<input type="button" class="standalone" name="form.buttons.cancel"
i18n:attributes="value" value="Cancel" />
</div>
</form>
</div>
</div>
</body>
</html>
20 changes: 4 additions & 16 deletions src/imio/esign/browser/templates/session_files.pt
Original file line number Diff line number Diff line change
@@ -1,20 +1,8 @@
<div tal:define="files view/files" i18n:domain="imio.esign">
<div tal:define="files view/files;
template python: context.unrestrictedTraverse('@@esign-files-list-macro').index"
i18n:domain="imio.esign">
<tal:none tal:condition="python:not files">
<span i18n:translate="">No files</span>
</tal:none>
<ol tal:condition="files">
<tal:loop repeat="file files">
<li tal:define="oddrow repeat/file/odd;
classOddEven python: oddrow and 'even' or 'odd';"
tal:attributes="class classOddEven">
<a tal:condition="python: context.unrestrictedTraverse('@@session-annotation-info').available()"
tal:attributes="href python:file[0].absolute_url() + '/@@session-annotation-info?context_uid=' + file[0].UID()"
target="_blank"
class="link-overlay-info">
<span class="fa fa-info-circle" title="Annotation info"></span>
</a>
<tal:link tal:replace="structure python:view.get_file_link(file[0], file[1])">link</tal:link>
</li>
</tal:loop>
</ol>
<metal:list use-macro="python: template.macros['files_list']" />
</div>
Loading
Loading