Skip to content

Commit a7b41c3

Browse files
committed
Auto delete old esign sessions
1 parent 31a2c82 commit a7b41c3

10 files changed

Lines changed: 235 additions & 4 deletions

File tree

CHANGES.rst

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ Changelog
55
1.0b8 (unreleased)
66
------------------
77

8-
- Nothing changed yet.
8+
- Auto delete old esign sessions.
9+
[chris-adam]
910

1011

1112
1.0b7 (2026-04-02)
@@ -36,10 +37,12 @@ Changelog
3637
------------------
3738

3839
- Renamed imio.esign config functions.
39-
[cadam]
40+
[chris-adam]
4041
- Highlight `draft` session in table view and viewlet, use `Id` as
4142
column header instead `identifier` so it is more narrow and
4243
make `sessions` view columns sortable.
44+
[chris-adam]
45+
- Highlight `draft` session and make `sessions` view columns sortable.
4346
[gbastien]
4447

4548
1.0b3 (2026-03-26)
@@ -130,4 +133,4 @@ Changelog
130133
------------------
131134

132135
- Initial release.
133-
[sgeulette, gbastien, aduchene, cadam]
136+
[sgeulette, gbastien, aduchene, chris-adam]

src/imio/esign/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
logger = logging.getLogger("imio.esign")
1414
PLONE_VERSION = int(api.env.plone_version()[0])
1515
API_ROOT_URL = os.getenv("API_ROOT_URL", "http://127.0.0.1:8000")
16+
CLEANUP_THROTTLE_HOURS = 24
1617
manage_session_perm = "imio.esign: Manage Sessions"
1718

1819

src/imio/esign/browser/settings.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,17 @@ class IImioEsignSettings(Interface):
112112
required=False,
113113
)
114114

115+
auto_cleanup_days = schema.Int(
116+
title=_("Auto-cleanup delay (days)"),
117+
description=_(
118+
"Number of days after a session enters 'to_sign' state before it is automatically "
119+
"deleted (finalized sessions only). Set to 0 to disable auto-cleanup."
120+
),
121+
default=100,
122+
min=0,
123+
required=False,
124+
)
125+
115126

116127
class ImioEsignSettings(RegistryEditForm):
117128
schema = IImioEsignSettings

src/imio/esign/browser/table.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from imio.esign import _
66
from imio.esign.config import get_esign_registry_seal_code
77
from imio.esign.config import get_esign_registry_seal_email
8+
from imio.esign.utils import get_deletion_date_msg
89
from imio.esign.utils import get_state_description
910
from imio.helpers.security import check_zope_admin
1011
from imio.pyutils.utils import safe_encode
@@ -60,6 +61,9 @@ def renderCell(self, item):
6061
))
6162
title = escape(translate(get_state_description(item.get("state", "")), context=self.request,
6263
domain="imio.esign"))
64+
deletion_msg = escape(get_deletion_date_msg(item, self.request))
65+
if deletion_msg:
66+
title = title + u"\n\n" + deletion_msg
6367
return (u"<span class='state-title state-title-{state_title_value}' title='{title}'>{state} <span class='far fa-question-circle' />"
6468
u"</span>".format(state=state, title=title, state_title_value=item.get("state")))
6569

src/imio/esign/browser/templates/macros.pt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<td class="table_widget_label"><label i18n:translate="">State</label></td>
1212
<td class="table_widget_value">
1313
<span tal:attributes="class string:state-title state-title-${session/state};
14-
title python:view.get_state_description(session['state'])">
14+
title python:view.get_state_title(session)">
1515
<tal:block content="python:session['state']" i18n:translate="">draft</tal:block>
1616
<span class='far fa-question-circle' />
1717
</span>

src/imio/esign/browser/views.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
from imio.esign.config import get_esign_registry_enabled
1313
from imio.esign.config import get_esign_registry_parapheo_url
1414
from imio.esign.config import get_esign_registry_signing_users_email_content
15+
from imio.esign.utils import cleanup_expired_sessions
1516
from imio.esign.utils import create_external_session
17+
from imio.esign.utils import get_deletion_date_msg
1618
from imio.esign.utils import get_session_annotation
1719
from imio.esign.utils import get_session_info
1820
from imio.esign.utils import get_sessions_for
@@ -41,6 +43,7 @@
4143

4244
import csv
4345
import json
46+
import logging
4447
import os
4548

4649

@@ -50,6 +53,9 @@
5053
from io import StringIO # Python 3
5154

5255

56+
logger = logging.getLogger(__name__)
57+
58+
5359
class SessionsListingView(BrowserView):
5460
"""View to list sessions."""
5561

@@ -61,6 +67,10 @@ def __init__(self, context, request):
6167
def __call__(self):
6268
if not self.available():
6369
raise Unauthorized
70+
try:
71+
cleanup_expired_sessions()
72+
except Exception:
73+
logger.exception("Auto-cleanup failed in SessionsListingView.__call__")
6474
return super(SessionsListingView, self).__call__()
6575

6676
def available(self):
@@ -262,6 +272,15 @@ def collapsible_content_css_default(self):
262272
def get_state_description(self, state):
263273
return translate(get_state_description(state), context=self.request, domain="imio.esign")
264274

275+
def get_state_title(self, session):
276+
"""Return state description with auto-deletion date appended for finalized sessions."""
277+
state = session.get("state", "")
278+
title = translate(get_state_description(state), context=self.request, domain="imio.esign")
279+
deletion_msg = get_deletion_date_msg(session, self.request)
280+
if deletion_msg:
281+
title = title + u"\n\n" + deletion_msg
282+
return title
283+
265284

266285
class ItemSessionInfoViewlet(FacetedSessionInfoViewlet):
267286
"""Show session info for all sessions linked to a context item."""

src/imio/esign/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ def get_esign_registry_external_watchers():
4646
return [ew.strip() for ew in value.split(",") if ew.strip()]
4747

4848

49+
def get_esign_registry_auto_cleanup_days(default=100):
50+
return api.portal.get_registry_record("imio.esign.auto_cleanup_days", default=default)
51+
52+
4953
def set_esign_registry_enabled(value):
5054
api.portal.set_registry_record("imio.esign.enabled", value)
5155

@@ -86,6 +90,10 @@ def set_esign_registry_external_watchers(value):
8690
api.portal.set_registry_record("imio.esign.external_watchers", value)
8791

8892

93+
def set_esign_registry_auto_cleanup_days(value):
94+
api.portal.set_registry_record("imio.esign.auto_cleanup_days", value)
95+
96+
8997
SIGNERS_EMAIL_CONTENT = u"""
9098
<meta charset="UTF-8"><tal:global>
9199
<p style="font-weight: bold;" tal:condition="nothing">!! Attention: ne pas modifier ceci directement mais passer

src/imio/esign/services/external_session_feedback.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def reply(self): # noqa C901
4747
if code == 21:
4848
# sign_session_confirmed
4949
session_update["state"] = "to_sign"
50+
session_update["to_sign_date"] = datetime.now()
5051
if value and "sign_session_url" in value and not session["sign_url"]:
5152
session_update["sign_url"] = value["sign_session_url"]
5253
elif code == 22:

src/imio/esign/tests/test_utils.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@
33
from collections import OrderedDict
44
from collective.iconifiedcategory.utils import calculate_category_id
55
from datetime import date
6+
from datetime import datetime
67
from datetime import timedelta
78
from imio.esign.config import get_esign_registry_max_session_size
9+
from imio.esign.config import set_esign_registry_auto_cleanup_days
810
from imio.esign.config import set_esign_registry_external_watchers
911
from imio.esign.config import set_esign_registry_max_session_size
1012
from imio.esign.testing import IMIO_ESIGN_INTEGRATION_TESTING
1113
from imio.esign.utils import add_files_to_session
14+
from imio.esign.utils import cleanup_expired_sessions
1215
from imio.esign.utils import create_external_session
1316
from imio.esign.utils import get_file_download_url
1417
from imio.esign.utils import get_file_info
@@ -810,6 +813,108 @@ def test_create_external_session_both_payload(self):
810813
},
811814
)
812815

816+
def test_cleanup_expired_sessions(self):
817+
"""Auto-cleanup removes expired finalized sessions and respects the delay and throttle."""
818+
signers = [("user1", "user1@sign.com", "User 1", "Position 1")]
819+
annot = get_session_annotation()
820+
self.addCleanup(set_esign_registry_auto_cleanup_days, 100)
821+
822+
# --- Eligibility: only finalized sessions are cleaned up ---
823+
824+
# Draft session with old last_update: never removed
825+
sid_draft, session_draft = add_files_to_session(signers, (self.uids[0],), discriminators=("draft",))
826+
session_draft["last_update"] = datetime.now() - timedelta(days=200)
827+
annot["last_cleanup"] = datetime.min
828+
cleanup_expired_sessions()
829+
self.assertIn(sid_draft, annot["sessions"])
830+
self.assertIn("last_cleanup", annot)
831+
832+
# Non-finalized states (sent, to_sign, returned, etc.): never removed regardless of age
833+
for state in ("sent", "to_sign", "returned", "refused", "signed", "errored"):
834+
sid, session = add_files_to_session(
835+
signers, (self.uids[1],), discriminators=(state,)
836+
)
837+
session["state"] = state
838+
session["last_update"] = datetime.now() - timedelta(days=200)
839+
annot["last_cleanup"] = datetime.min
840+
cleanup_expired_sessions()
841+
self.assertIn(sid, annot["sessions"], "State %s should not be cleaned up" % state)
842+
del annot["sessions"][sid] # clean up manually for next iteration
843+
844+
# --- Expiry based on last_update (fallback when to_sign_date absent) ---
845+
846+
# Finalized, last_update within window: not removed
847+
sid_recent_fin, session_recent_fin = add_files_to_session(
848+
signers, (self.uids[1],), discriminators=("recent_fin",)
849+
)
850+
session_recent_fin["state"] = "finalized"
851+
session_recent_fin["last_update"] = datetime.now() - timedelta(days=50)
852+
annot["last_cleanup"] = datetime.min
853+
cleanup_expired_sessions()
854+
self.assertIn(sid_recent_fin, annot["sessions"])
855+
856+
# Finalized, last_update expired (> 100 days), no to_sign_date: removed
857+
sid_old_fin, session_old_fin = add_files_to_session(
858+
signers, (self.uids[2],), discriminators=("old_fin",)
859+
)
860+
session_old_fin["state"] = "finalized"
861+
session_old_fin["last_update"] = datetime.now() - timedelta(days=101)
862+
annot["last_cleanup"] = datetime.min
863+
cleanup_expired_sessions()
864+
self.assertNotIn(sid_old_fin, annot["sessions"])
865+
self.assertNotIn(self.uids[2], annot["uids"])
866+
self.assertIn(sid_recent_fin, annot["sessions"]) # recent session untouched
867+
868+
# --- Expiry based on to_sign_date (takes priority over last_update) ---
869+
870+
# to_sign_date expired but last_update recent: to_sign_date wins → removed
871+
sid_ts_old, session_ts_old = add_files_to_session(
872+
signers, (self.uids[2],), discriminators=("ts_old",)
873+
)
874+
session_ts_old["state"] = "finalized"
875+
session_ts_old["to_sign_date"] = datetime.now() - timedelta(days=101)
876+
session_ts_old["last_update"] = datetime.now() - timedelta(days=1)
877+
annot["last_cleanup"] = datetime.min
878+
cleanup_expired_sessions()
879+
self.assertNotIn(sid_ts_old, annot["sessions"])
880+
881+
# to_sign_date within window but last_update old: to_sign_date wins → kept
882+
sid_ts_recent, session_ts_recent = add_files_to_session(
883+
signers, (self.uids[2],), discriminators=("ts_recent",)
884+
)
885+
session_ts_recent["state"] = "finalized"
886+
session_ts_recent["to_sign_date"] = datetime.now() - timedelta(days=50)
887+
session_ts_recent["last_update"] = datetime.now() - timedelta(days=200)
888+
annot["last_cleanup"] = datetime.min
889+
cleanup_expired_sessions()
890+
self.assertIn(sid_ts_recent, annot["sessions"])
891+
892+
# --- Throttle ---
893+
894+
sid_throttle, session_throttle = add_files_to_session(
895+
signers, (self.uids[3],), discriminators=("throttle",)
896+
)
897+
session_throttle["state"] = "finalized"
898+
session_throttle["last_update"] = datetime.now() - timedelta(days=101)
899+
annot["last_cleanup"] = datetime.now() - timedelta(hours=1) # ran 1h ago
900+
cleanup_expired_sessions()
901+
self.assertIn(sid_throttle, annot["sessions"]) # throttled: skipped
902+
903+
# --- Disabled (0 days) ---
904+
905+
set_esign_registry_auto_cleanup_days(0)
906+
annot["last_cleanup"] = datetime.min
907+
cleanup_expired_sessions()
908+
self.assertIn(sid_throttle, annot["sessions"]) # disabled: skipped
909+
910+
# --- Custom delay: 10 days ---
911+
912+
set_esign_registry_auto_cleanup_days(10)
913+
# session_throttle has last_update 101 days ago → expired under 10-day rule
914+
annot["last_cleanup"] = datetime.min
915+
cleanup_expired_sessions()
916+
self.assertNotIn(sid_throttle, annot["sessions"])
917+
813918

814919
# example of annotation content
815920
"""

src/imio/esign/utils.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
from datetime import timedelta
77
from imio.esign import _tr as _
88
from imio.esign import API_ROOT_URL
9+
from imio.esign import CLEANUP_THROTTLE_HOURS
910
from imio.esign import logger
1011
from imio.esign.audit import audit
12+
from imio.esign.config import get_esign_registry_auto_cleanup_days
1113
from imio.esign.config import get_esign_registry_external_watchers
1214
from imio.esign.config import get_esign_registry_file_url
1315
from imio.esign.config import get_esign_registry_max_session_size
@@ -27,6 +29,7 @@
2729
from plone import api
2830
from zope.annotation import IAnnotations
2931
from zope.component import getAdapter
32+
from zope.i18n import translate
3033

3134
import json
3235
import requests
@@ -469,6 +472,82 @@ def remove_session(session_id):
469472
# logger.info("Session %s removed", session_id)
470473

471474

475+
def get_session_deletion_date(session, cleanup_days=None):
476+
"""Return the expected auto-deletion date for a finalized session, or None.
477+
478+
Deletion is scheduled ``cleanup_days`` days after the session entered
479+
the 'to_sign' state. Falls back to ``last_update`` when ``to_sign_date``
480+
was not recorded (sessions created before this feature). Returns ``None``
481+
when the session is not in 'finalized' state or auto-cleanup is disabled
482+
(cleanup_days == 0).
483+
"""
484+
if session.get("state") != "finalized":
485+
return None
486+
if cleanup_days is None:
487+
cleanup_days = get_esign_registry_auto_cleanup_days()
488+
if not cleanup_days:
489+
return None
490+
reference_date = session.get("to_sign_date") or session.get("last_update")
491+
if not reference_date:
492+
return None
493+
deletion_date = reference_date + timedelta(days=cleanup_days)
494+
annot = get_session_annotation()
495+
return max(deletion_date, annot["last_cleanup"] + timedelta(hours=CLEANUP_THROTTLE_HOURS))
496+
497+
498+
def get_deletion_date_msg(session, request):
499+
"""Return translated deletion date message for a finalized session, or empty string."""
500+
deletion_date = get_session_deletion_date(session)
501+
if not deletion_date:
502+
return u""
503+
return translate(
504+
_("The session will be automatically deleted on ${date}. "
505+
"Files won't be deleted from the application, but the session won't be accessible anymore.",
506+
mapping={"date": deletion_date.strftime("%d/%m/%Y %H:%M")}),
507+
context=request, domain="imio.esign",
508+
)
509+
510+
511+
def cleanup_expired_sessions():
512+
"""Remove finalized sessions that have exceeded the auto_cleanup_days delay.
513+
514+
The delay is counted from ``to_sign_date`` when available, falling back
515+
to ``last_update`` for older sessions that predate this feature.
516+
517+
Throttled to run at most once every CLEANUP_THROTTLE_HOURS hours via a
518+
'last_cleanup' timestamp stored in the portal annotation.
519+
"""
520+
cleanup_days = get_esign_registry_auto_cleanup_days()
521+
if not cleanup_days:
522+
return
523+
524+
annot = get_session_annotation()
525+
now = datetime.now()
526+
527+
last_cleanup = annot.get("last_cleanup", datetime.min)
528+
if now - last_cleanup < timedelta(hours=CLEANUP_THROTTLE_HOURS):
529+
return
530+
531+
expired = [
532+
sid for sid, session in annot["sessions"].items()
533+
if (get_session_deletion_date(session, cleanup_days=cleanup_days) or now) < now
534+
]
535+
536+
for session_id in expired:
537+
session = annot["sessions"].get(session_id)
538+
if session:
539+
logger.info(
540+
"Auto-cleanup: removing expired session %s (state=%s, to_sign_date=%s, sign_id=%s)",
541+
session_id,
542+
session.get("state"),
543+
session.get("to_sign_date"),
544+
session.get("sign_id"),
545+
)
546+
remove_session(session_id)
547+
548+
annot["last_cleanup"] = now
549+
550+
472551
def get_file_download_url(uid, root_url=None, short_uid=None):
473552
"""Get the file download URL for a given file UID.
474553

0 commit comments

Comments
 (0)