From d6b3e151242296dd325fb7646119f36b191d686a Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Mon, 20 Apr 2026 12:51:49 -0400 Subject: [PATCH 01/13] wip: electronic signatures --- .../doctype/electronic_signature/__init__.py | 2 + .../electronic_signature.js | 8 + .../electronic_signature.json | 144 ++++++++++++ .../electronic_signature.py | 198 ++++++++++++++++ .../test_electronic_signature.py | 9 + .../electronic_signature_signer/__init__.py | 2 + .../electronic_signature_signer.json | 73 ++++++ .../electronic_signature_signer.py | 8 + communications/communications/install.py | 28 +++ communications/communications/signatures.py | 50 ++++ communications/hooks.py | 16 ++ .../public/js/electronic_signature.js | 45 ++++ communications/tests/setup.py | 6 +- .../tests/test_electronic_signature.py | 154 +++++++++++++ .../electronic_signature/[name]/__init__.py | 2 + .../electronic_signature/[name]/index.html | 214 ++++++++++++++++++ .../www/electronic_signature/[name]/index.py | 76 +++++++ .../www/electronic_signature/__init__.py | 44 ++++ .../www/electronic_signature/index.html | 49 ++++ .../www/electronic_signature/index.py | 67 ++++++ .../www/electronic_signature/portal_paths.py | 12 + 21 files changed, 1206 insertions(+), 1 deletion(-) create mode 100644 communications/communications/doctype/electronic_signature/__init__.py create mode 100644 communications/communications/doctype/electronic_signature/electronic_signature.js create mode 100644 communications/communications/doctype/electronic_signature/electronic_signature.json create mode 100644 communications/communications/doctype/electronic_signature/electronic_signature.py create mode 100644 communications/communications/doctype/electronic_signature/test_electronic_signature.py create mode 100644 communications/communications/doctype/electronic_signature_signer/__init__.py create mode 100644 communications/communications/doctype/electronic_signature_signer/electronic_signature_signer.json create mode 100644 communications/communications/doctype/electronic_signature_signer/electronic_signature_signer.py create mode 100644 communications/communications/signatures.py create mode 100644 communications/public/js/electronic_signature.js create mode 100644 communications/tests/test_electronic_signature.py create mode 100644 communications/www/electronic_signature/[name]/__init__.py create mode 100644 communications/www/electronic_signature/[name]/index.html create mode 100644 communications/www/electronic_signature/[name]/index.py create mode 100644 communications/www/electronic_signature/__init__.py create mode 100644 communications/www/electronic_signature/index.html create mode 100644 communications/www/electronic_signature/index.py create mode 100644 communications/www/electronic_signature/portal_paths.py diff --git a/communications/communications/doctype/electronic_signature/__init__.py b/communications/communications/doctype/electronic_signature/__init__.py new file mode 100644 index 0000000..ea2ab3f --- /dev/null +++ b/communications/communications/doctype/electronic_signature/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt diff --git a/communications/communications/doctype/electronic_signature/electronic_signature.js b/communications/communications/doctype/electronic_signature/electronic_signature.js new file mode 100644 index 0000000..2e034d6 --- /dev/null +++ b/communications/communications/doctype/electronic_signature/electronic_signature.js @@ -0,0 +1,8 @@ +// Copyright (c) 2026, AgriTheory and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Electronic Signature", { +// refresh(frm) { + +// }, +// }); diff --git a/communications/communications/doctype/electronic_signature/electronic_signature.json b/communications/communications/doctype/electronic_signature/electronic_signature.json new file mode 100644 index 0000000..bbe7fd3 --- /dev/null +++ b/communications/communications/doctype/electronic_signature/electronic_signature.json @@ -0,0 +1,144 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "naming_series:", + "creation": "2026-04-16 12:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "naming_series", + "title", + "status", + "column_break_pooc", + "reference_doctype", + "reference_name", + "print_format", + "intro_section", + "introduction", + "signers_section", + "signers" + ], + "fields": [ + { + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Series", + "no_copy": 1, + "options": "ESIG-.YYYY.-", + "print_hide": 1, + "reqd": 1, + "set_only_once": 1 + }, + { + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Title", + "reqd": 1 + }, + { + "default": "Draft", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "options": "Draft\nOut for Signature\nCompleted\nCancelled", + "reqd": 1 + }, + { + "fieldname": "reference_doctype", + "fieldtype": "Link", + "label": "Reference DocType", + "options": "DocType" + }, + { + "fieldname": "reference_name", + "fieldtype": "Dynamic Link", + "label": "Reference Name", + "options": "reference_doctype" + }, + { + "fieldname": "introduction", + "fieldtype": "Text Editor", + "label": "Introduction" + }, + { + "fieldname": "signers_section", + "fieldtype": "Section Break", + "label": "Signers" + }, + { + "fieldname": "signers", + "fieldtype": "Table", + "label": "Signers", + "options": "Electronic Signature Signer" + }, + { + "fieldname": "column_break_pooc", + "fieldtype": "Column Break" + }, + { + "fieldname": "intro_section", + "fieldtype": "Section Break" + }, + { + "fieldname": "print_format", + "fieldtype": "Link", + "label": "Print Format", + "options": "Print Format" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-04-16 15:24:01.144254", + "modified_by": "Administrator", + "module": "Communications", + "name": "Electronic Signature", + "naming_rule": "By \"Naming Series\" field", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Projects User", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Projects Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/communications/communications/doctype/electronic_signature/electronic_signature.py b/communications/communications/doctype/electronic_signature/electronic_signature.py new file mode 100644 index 0000000..3d41ba6 --- /dev/null +++ b/communications/communications/doctype/electronic_signature/electronic_signature.py @@ -0,0 +1,198 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt + +import json +import time + +import frappe +from frappe import _ +from frappe.model.document import Document + +DEBUG_LOG_PATH = "/home/tyler/arrowwood/apps/.cursor/debug-c0c347.log" +DEBUG_SESSION_ID = "c0c347" + + +# #region agent log +def agent_debug_log(hypothesis_id, location, message, data=None): + try: + payload = { + "sessionId": DEBUG_SESSION_ID, + "hypothesisId": hypothesis_id, + "location": location, + "message": message, + "data": data or {}, + "timestamp": int(time.time() * 1000), + } + with open(DEBUG_LOG_PATH, "a", encoding="utf-8") as log_file: + log_file.write(json.dumps(payload, default=str) + "\n") + except Exception: + pass + + +# #endregion + + +def website_portal_signer_may_read(doc, user): + """Website User may read this doc on the portal when they are a listed signer (contact match).""" + if not doc or not user or user == "Guest": + return False + user_type = frappe.db.get_value("User", user, "user_type") + if user_type != "Website User": + return False + if doc.status not in ("Out for Signature", "Completed"): + return False + contact = frappe.db.get_value("Contact", {"user": user}, "name") + if not contact: + return False + for row in doc.signers or []: + if row.contact == contact: + return True + return False + + +class ElectronicSignature(Document): + @property + def document_html(self): + """HTML from the reference document's print view (no DB field).""" + if not (self.reference_doctype and self.reference_name): + return "" + try: + ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name) + return frappe.get_print( + self.reference_doctype, + self.reference_name, + print_format=self.print_format or None, + doc=ref_doc, + ) + except Exception: + frappe.log_error( + title="Electronic Signature document_html print render", + message=frappe.get_traceback(), + ) + return "" + + def has_permission(self, permtype="read", *, debug=False, user=None): + # #region agent log + user = user or frappe.session.user + agent_debug_log( + "H2_class_override", + "electronic_signature.py:ElectronicSignature.has_permission", + "entry", + { + "permtype": permtype, + "user": user, + "doc": getattr(self, "name", None), + "status": getattr(self, "status", None), + "ignore_permissions": bool(getattr(self.flags, "ignore_permissions", None)), + }, + ) + # #endregion + if getattr(self.flags, "ignore_permissions", None): + return True + if permtype == "read" and website_portal_signer_may_read(self, user): + # #region agent log + agent_debug_log( + "H2_class_override", + "electronic_signature.py:ElectronicSignature.has_permission", + "granted_via_portal_signer", + {"doc": self.name, "user": user}, + ) + # #endregion + return True + out = super().has_permission(permtype, debug=debug, user=user) + # #region agent log + agent_debug_log( + "H2_class_override", + "electronic_signature.py:ElectronicSignature.has_permission", + "delegated_result", + {"doc": self.name, "user": user, "permtype": permtype, "out": out}, + ) + # #endregion + return out + + def validate(self): + if self.reference_doctype and not self.reference_name: + frappe.throw(_("Reference Name is required when Reference DocType is set.")) + if self.reference_name and not self.reference_doctype: + frappe.throw(_("Reference DocType is required when Reference Name is set.")) + if self.print_format and not (self.reference_doctype and self.reference_name): + frappe.throw(_("Set a reference document before choosing a Print Format.")) + if self.print_format and self.reference_doctype: + pf_doctype = frappe.db.get_value("Print Format", self.print_format, "doc_type") + if pf_doctype != self.reference_doctype: + frappe.throw(_("Print Format must be for the same DocType as the reference.")) + if self.reference_doctype and self.reference_name: + if not frappe.db.exists(self.reference_doctype, self.reference_name): + frappe.throw(_("Referenced document does not exist.")) + if self.status == "Out for Signature": + if not self.signers: + frappe.throw(_("Add at least one signer before sending for signature.")) + for row in self.signers: + if not row.email: + frappe.throw(_("Each signer must have an email.")) + if self.status == "Completed": + for row in self.signers or []: + if not row.executed_at: + frappe.throw(_("All signers must have executed before status can be Completed.")) + + +def has_electronic_signature_permission(doc, user=None, ptype="read"): + """Hook: deny portal-ineligible Website Users only; never return True (that does not grant DocPerm read).""" + user = user or frappe.session.user + # #region agent log + agent_debug_log( + "H1_hook", + "electronic_signature.py:has_electronic_signature_permission", + "entry", + { + "ptype": ptype, + "user": user, + "doc": getattr(doc, "name", doc) if doc is not None else None, + "doc_status": getattr(doc, "status", None) if doc is not None else None, + }, + ) + # #endregion + if not doc: + return None + if ptype != "read": + return None + user_type = frappe.db.get_value("User", user, "user_type") + if user_type == "System User": + # #region agent log + agent_debug_log( + "H1_hook", + "electronic_signature.py:has_electronic_signature_permission", + "system_user_defer", + {}, + ) + # #endregion + return None + if user_type != "Website User": + # #region agent log + agent_debug_log( + "H3_user_type", + "electronic_signature.py:has_electronic_signature_permission", + "not_website_user_false", + {"user_type": user_type}, + ) + # #endregion + return False + if website_portal_signer_may_read(doc, user): + # #region agent log + agent_debug_log( + "H1_hook", + "electronic_signature.py:has_electronic_signature_permission", + "signer_return_none", + {"doc": doc.name}, + ) + # #endregion + return None + # #region agent log + agent_debug_log( + "H1_hook", + "electronic_signature.py:has_electronic_signature_permission", + "non_signer_false", + {"doc": doc.name, "user": user}, + ) + # #endregion + return False diff --git a/communications/communications/doctype/electronic_signature/test_electronic_signature.py b/communications/communications/doctype/electronic_signature/test_electronic_signature.py new file mode 100644 index 0000000..fa6de7a --- /dev/null +++ b/communications/communications/doctype/electronic_signature/test_electronic_signature.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, AgriTheory and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestElectronicSignature(FrappeTestCase): + pass diff --git a/communications/communications/doctype/electronic_signature_signer/__init__.py b/communications/communications/doctype/electronic_signature_signer/__init__.py new file mode 100644 index 0000000..ea2ab3f --- /dev/null +++ b/communications/communications/doctype/electronic_signature_signer/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt diff --git a/communications/communications/doctype/electronic_signature_signer/electronic_signature_signer.json b/communications/communications/doctype/electronic_signature_signer/electronic_signature_signer.json new file mode 100644 index 0000000..6d3edaf --- /dev/null +++ b/communications/communications/doctype/electronic_signature_signer/electronic_signature_signer.json @@ -0,0 +1,73 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-04-16 12:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "contact", + "contact_name", + "email", + "executed_at", + "column_break_pbds", + "signature" + ], + "fields": [ + { + "fieldname": "contact", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Contact", + "options": "Contact" + }, + { + "fetch_from": "contact.full_name", + "fieldname": "contact_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Contact Name" + }, + { + "fetch_from": "contact.email_id", + "fieldname": "email", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Email", + "reqd": 1 + }, + { + "fieldname": "executed_at", + "fieldtype": "Datetime", + "in_list_view": 1, + "label": "Executed At", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "column_break_pbds", + "fieldtype": "Column Break" + }, + { + "fieldname": "signature", + "fieldtype": "Signature", + "in_list_view": 1, + "label": "Signature", + "no_copy": 1, + "read_only": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-04-16 12:00:00.000000", + "modified_by": "Administrator", + "module": "Communications", + "name": "Electronic Signature Signer", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/communications/communications/doctype/electronic_signature_signer/electronic_signature_signer.py b/communications/communications/doctype/electronic_signature_signer/electronic_signature_signer.py new file mode 100644 index 0000000..c3c2aa5 --- /dev/null +++ b/communications/communications/doctype/electronic_signature_signer/electronic_signature_signer.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class ElectronicSignatureSigner(Document): + pass diff --git a/communications/communications/install.py b/communications/communications/install.py index 42e8e3d..15873b5 100644 --- a/communications/communications/install.py +++ b/communications/communications/install.py @@ -11,6 +11,12 @@ def after_install(): """Create default notifications after app installation.""" create_default_notifications() + create_electronic_signature_email_template() + + +def after_migrate(): + """Ensure optional records exist after schema changes.""" + create_electronic_signature_email_template() def create_default_notifications(): @@ -196,3 +202,25 @@ def create_default_notifications(): } ) doc.insert(ignore_permissions=True) + + +def create_electronic_signature_email_template(): + name = "Electronic Signature Request" + if frappe.db.exists("Email Template", name): + return + doc = frappe.get_doc( + { + "doctype": "Email Template", + "name": name, + "subject": "Please sign: {{ title }}", + "use_html": 1, + "response_html": """

Hello,

+

Please review and sign the following document: {{ title }}

+{% if introduction %}{{ introduction }}{% endif %} +

Use the secure link below to open the signing page.

+

MAGIC_LINK

+

If you did not expect this message, you can ignore it.

+""", + } + ) + doc.insert(ignore_permissions=True) diff --git a/communications/communications/signatures.py b/communications/communications/signatures.py new file mode 100644 index 0000000..1a8558e --- /dev/null +++ b/communications/communications/signatures.py @@ -0,0 +1,50 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt + +""" +Electronic signature invitations and magic login links. + +``get_magic_link`` builds a one-time login URL when **Login with Email Link** is enabled +in System Settings. For the link to land on the signing page after login, the site must +honor the ``redirect-to`` query parameter on the login-key handler. Frappe core may not +apply that redirect; washmoreerp implements this by overriding +``frappe.www.login.login_via_key``. Sites using the signing portal can copy that pattern +into their own app if needed. +""" + +import json +from urllib.parse import quote + +import frappe +from frappe import _ +from frappe.www.login import _generate_temporary_login_link + + +def get_magic_link(email, redirect_to=None): + if not frappe.get_system_settings("login_with_email_link"): + return "" + expiry = frappe.get_system_settings("login_with_email_link_expiry") or 10 + link = _generate_temporary_login_link(email, expiry) + if redirect_to: + sep = "&" if "?" in link else "?" + link = f"{link}{sep}redirect-to={quote(redirect_to)}" + return link + + +@frappe.whitelist() +def fetch_signature_invitation_email(doc, recipient): + doc = frappe._dict(json.loads(doc)) if isinstance(doc, str) else doc + full = frappe.get_doc("Electronic Signature", doc.name) + template_name = "Electronic Signature Request" + if not frappe.db.exists("Email Template", template_name): + frappe.throw(_("Email Template {0} not found").format(template_name)) + template = frappe.get_doc("Email Template", template_name) + formatted = template.get_formatted_email(full.as_dict()) + redirect_path = f"/sign/{full.name}" + magic_link = get_magic_link(recipient, redirect_to=redirect_path) + message = formatted["message"].replace("\n", "
").replace("MAGIC_LINK", magic_link or "") + return { + "recipients": recipient, + "subject": formatted["subject"], + "email_message": message, + } diff --git a/communications/hooks.py b/communications/hooks.py index 85289cf..f732785 100644 --- a/communications/hooks.py +++ b/communications/hooks.py @@ -61,6 +61,7 @@ # before_install = "communications.install.before_install" after_install = "communications.communications.install.after_install" +after_migrate = "communications.communications.install.after_migrate" # before_uninstall = "communications.uninstall.before_uninstall" # after_uninstall = "communications.uninstall.after_uninstall" @@ -85,6 +86,21 @@ "Notification": "communications.communications.overrides.notification.CommunicationsNotification", } +has_permission = { + "Electronic Signature": "communications.communications.doctype.electronic_signature.electronic_signature.has_electronic_signature_permission", +} + +website_route_rules = [ + {"from_route": "/electronic_signature/", "to_route": "electronic_signature/[name]"}, + {"from_route": "/electronic_signature", "to_route": "electronic_signature"}, + {"from_route": "/sign/", "to_route": "electronic_signature/[name]"}, + {"from_route": "/sign", "to_route": "electronic_signature"}, +] + +doctype_js = { + "Electronic Signature": "public/js/electronic_signature.js", +} + doc_events = { "Event": { "validate": "communications.communications.overrides.event.validate", diff --git a/communications/public/js/electronic_signature.js b/communications/public/js/electronic_signature.js new file mode 100644 index 0000000..d912132 --- /dev/null +++ b/communications/public/js/electronic_signature.js @@ -0,0 +1,45 @@ +// Copyright (c) 2026, AgriTheory and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Electronic Signature', { + onload_post_render(frm) { + if (['Draft', 'Out for Signature'].includes(frm.doc.status)) { + add_send_signature_invitations_button(frm) + } + }, + refresh(frm) { + if (['Draft', 'Out for Signature'].includes(frm.doc.status)) { + add_send_signature_invitations_button(frm) + } + }, +}) + +function add_send_signature_invitations_button(frm) { + frm.page.add_action_item(__('Send signature invitations'), () => { + if (!frm.doc.signers || !frm.doc.signers.length) { + frappe.msgprint(__('Add at least one signer first.')) + return + } + frm.doc.signers.forEach(row => { + const recipient = row.email + if (!recipient) { + return + } + frappe + .xcall('communications.communications.signatures.fetch_signature_invitation_email', { + doc: frm.doc, + recipient: recipient, + }) + .then(r => { + return new frappe.views.CommunicationComposer({ + doc: frm.doc, + frm: frm, + subject: r.subject || __('Signature requested'), + recipients: r.recipients, + attach_document_print: false, + message: r.email_message || '', + }) + }) + }) + }) +} diff --git a/communications/tests/setup.py b/communications/tests/setup.py index bc3191d..54b8562 100644 --- a/communications/tests/setup.py +++ b/communications/tests/setup.py @@ -8,7 +8,10 @@ from frappe.utils.password import update_password -from communications.communications.install import create_default_notifications +from communications.communications.install import ( + create_default_notifications, + create_electronic_signature_email_template, +) from communications.tests.fixtures import employees, holidays @@ -65,6 +68,7 @@ def create_test_data(): create_employees(settings) add_holiday_lists() create_default_notifications() + create_electronic_signature_email_template() create_public_calendars() create_booked_event() diff --git a/communications/tests/test_electronic_signature.py b/communications/tests/test_electronic_signature.py new file mode 100644 index 0000000..9d82ed4 --- /dev/null +++ b/communications/tests/test_electronic_signature.py @@ -0,0 +1,154 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt + +import frappe +import pytest + +from communications.communications.install import create_electronic_signature_email_template +from communications.communications.doctype.electronic_signature.electronic_signature import ( + has_electronic_signature_permission, +) +from communications.www.electronic_signature import add_signature + + +@pytest.fixture +def signature_email_template(): + create_electronic_signature_email_template() + + +def make_website_signer_user(email, contact_first="Signer", contact_last="Test"): + if frappe.db.exists("User", email): + return frappe.get_doc("User", email) + user = frappe.new_doc("User") + user.email = email + user.first_name = contact_first + user.last_name = contact_last + user.enabled = 1 + user.send_welcome_email = 0 + user.user_type = "Website User" + user.append("roles", {"role": "Customer"}) + user.save() + + contact = frappe.new_doc("Contact") + contact.first_name = contact_first + contact.last_name = contact_last + contact.append("email_ids", {"email_id": email, "is_primary": 1}) + contact.user = user.name + contact.insert(ignore_permissions=True) + + return user + + +def make_electronic_signature_with_signers(signers_status="Out for Signature"): + suffix = frappe.generate_hash(length=8) + email = f"esign_test_{suffix}@example.com" + user = make_website_signer_user(email) + contact_name = frappe.db.get_value("Contact", {"user": email}, "name") + + es = frappe.new_doc("Electronic Signature") + es.title = "Test Agreement" + es.naming_series = "ESIG-.YYYY.-" + es.status = signers_status + es.introduction = "

Please read and sign.

" + es.append( + "signers", + { + "contact": contact_name, + "email": email, + }, + ) + es.insert(ignore_permissions=True) + return es, email, contact_name + + +def test_add_signature_completes_and_sets_status(signature_email_template): + es, email, contact_name = make_electronic_signature_with_signers() + frappe.set_user(email) + assert es.has_permission("read", user=email) is True + + payload = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + out = add_signature(es.name, payload) + assert out.get("success") is True + + es.reload() + assert es.status == "Completed" + assert es.signers[0].executed_at + assert es.signers[0].signature + + frappe.set_user("Administrator") + + +def test_add_signature_rejects_duplicate(signature_email_template): + es, email, contact_name = make_electronic_signature_with_signers() + frappe.set_user(email) + payload = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + add_signature(es.name, payload) + with pytest.raises(frappe.ValidationError): + add_signature(es.name, payload) + frappe.set_user("Administrator") + + +def test_add_signature_rejects_wrong_user(signature_email_template): + es, email, contact_name = make_electronic_signature_with_signers() + frappe.set_user("Administrator") + other_email = f"other_esign_{frappe.generate_hash(length=6)}@example.com" + other = make_website_signer_user(other_email) + frappe.set_user(other.email) + with pytest.raises(frappe.ValidationError): + add_signature( + es.name, + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + ) + frappe.set_user("Administrator") + + +def test_has_permission_denies_non_signer_customer(signature_email_template): + es, email, contact_name = make_electronic_signature_with_signers() + other_email = f"stranger_esign_{frappe.generate_hash(length=6)}@example.com" + other = make_website_signer_user(other_email) + assert has_electronic_signature_permission(es, user=other.name, ptype="read") is False + + +def test_fetch_signature_invitation_email(signature_email_template): + es, email, contact_name = make_electronic_signature_with_signers("Draft") + frappe.set_user("Administrator") + from communications.communications.signatures import fetch_signature_invitation_email + + out = fetch_signature_invitation_email(es.as_dict(), email) + assert "subject" in out + assert email in (out.get("recipients") or "") + + +def test_two_signers_then_completed(signature_email_template): + frappe.set_user("Administrator") + email_a = f"esign_a_{frappe.generate_hash(length=6)}@example.com" + email_b = f"esign_b_{frappe.generate_hash(length=6)}@example.com" + make_website_signer_user(email_a, "Alice", "One") + make_website_signer_user(email_b, "Bob", "Two") + contact_a = frappe.db.get_value("Contact", {"user": email_a}, "name") + contact_b = frappe.db.get_value("Contact", {"user": email_b}, "name") + + es = frappe.new_doc("Electronic Signature") + es.title = "Dual sign" + es.naming_series = "ESIG-.YYYY.-" + es.status = "Out for Signature" + es.append("signers", {"contact": contact_a, "email": email_a}) + es.append("signers", {"contact": contact_b, "email": email_b}) + es.insert(ignore_permissions=True) + + frappe.set_user(email_a) + add_signature( + es.name, + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + ) + es.reload() + assert es.status == "Out for Signature" + + frappe.set_user(email_b) + add_signature( + es.name, + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + ) + es.reload() + assert es.status == "Completed" + frappe.set_user("Administrator") diff --git a/communications/www/electronic_signature/[name]/__init__.py b/communications/www/electronic_signature/[name]/__init__.py new file mode 100644 index 0000000..ea2ab3f --- /dev/null +++ b/communications/www/electronic_signature/[name]/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt diff --git a/communications/www/electronic_signature/[name]/index.html b/communications/www/electronic_signature/[name]/index.html new file mode 100644 index 0000000..89519c5 --- /dev/null +++ b/communications/www/electronic_signature/[name]/index.html @@ -0,0 +1,214 @@ +{% extends "templates/web.html" %} + +{% block title %}{{ title }}{% endblock %} + +{% block header %}

{{ title }}

{% endblock %} + +{% block page_content %} +
+
+
+
+
+ {% if electronic_signature.introduction %} +
+ {{ electronic_signature.introduction }} +
+
+ {% endif %} + {% if electronic_signature.document_html %} +
+

{{ _("Document") }}

+
+ {{ electronic_signature.document_html | safe }} +
+
+
+ {% endif %} + {% if electronic_signature.signers and electronic_signature.signers|length > 0 %} +
+

{{ _("Signatures") }}

+
+ {% for signature in electronic_signature.signers %} +
+
+
+
+
+

{{ _("Party") }}: {{ signature.contact_name }}

+

{{ _("Email") }}: {{ signature.email }}

+

{{ _("Executed At") }}: {{ + frappe.utils.format_datetime(signature.executed_at) if signature.executed_at else _("Not set") + }}

+
+
+
+ {% if signature.email == active_signer_email %} +
+ {% if not signature.executed_at %} +
+ +
+ + +
+ {% endif %} +
+ {% else %} +
+ {% if signature.executed_at and signature.signature %} +
+ {{ _( +
+ {% endif %} +
+ {% endif %} +
+
+
+
+ {% endfor %} +
+
+ {% endif %} +
+
+
+
+
+{% endblock %} + +{% block script %} + + +{% endblock %} + + diff --git a/communications/www/electronic_signature/[name]/index.py b/communications/www/electronic_signature/[name]/index.py new file mode 100644 index 0000000..f414595 --- /dev/null +++ b/communications/www/electronic_signature/[name]/index.py @@ -0,0 +1,76 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ + +from communications.communications.doctype.electronic_signature.electronic_signature import ( + agent_debug_log, +) +from communications.www.electronic_signature.portal_paths import signature_portal_base + + +def get_context(context): + context.no_cache = 1 + name = frappe.form_dict.name + base = signature_portal_base() + context.signature_base = base + + if not name: + frappe.local.flags.redirect_location = base + raise frappe.Redirect + + try: + doc = frappe.get_doc("Electronic Signature", name) + except frappe.DoesNotExistError: + frappe.local.flags.redirect_location = base + raise frappe.Redirect + + # #region agent log + agent_debug_log( + "H5_portal_detail", + "www/electronic_signature/[name]/index.py:get_context", + "before_doc_has_permission", + {"doc": doc.name, "session_user": frappe.session.user}, + ) + # #endregion + + if not doc.has_permission("read"): + # #region agent log + agent_debug_log( + "H5_portal_detail", + "www/electronic_signature/[name]/index.py:get_context", + "doc_has_permission_read_denied", + {"doc": doc.name}, + ) + # #endregion + frappe.throw(_("Not permitted"), frappe.PermissionError) + + doc.add_viewed(user=frappe.session.user, force=True, unique_views=True) + + user = frappe.session.user + contact = get_contact_from_user(user) + contact_id = contact.name if contact else "" + contact_name = contact.full_name if contact else "" + + active_signer_email = None + if contact_id and doc.signers: + for row in doc.signers: + if row.contact == contact_id and not row.executed_at: + active_signer_email = row.email + break + + context.electronic_signature = doc + context.active_signer_email = active_signer_email + context.contact_id = contact_id + context.contact_name = contact_name + context.title = _("Sign") + ": " + (doc.title or doc.name) + + return context + + +def get_contact_from_user(user): + contact_name = frappe.db.get_value("Contact", {"user": user}) + if contact_name: + return frappe.get_doc("Contact", contact_name) + return None diff --git a/communications/www/electronic_signature/__init__.py b/communications/www/electronic_signature/__init__.py new file mode 100644 index 0000000..66d2022 --- /dev/null +++ b/communications/www/electronic_signature/__init__.py @@ -0,0 +1,44 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt + +import json + +import frappe +from frappe import _ +from frappe.utils.data import now_datetime + + +@frappe.whitelist() +def add_signature(electronic_signature_name, signature_data): + if not electronic_signature_name or not signature_data: + frappe.throw(_("Required information missing")) + + doc = frappe.get_doc("Electronic Signature", electronic_signature_name) + if doc.status not in ("Out for Signature",): + frappe.throw(_("This document is not open for signing.")) + + user = frappe.session.user + for row in doc.signers: + if row.email == user and row.executed_at: + frappe.throw(_("You have already signed this document.")) + + signature_value = ( + signature_data if isinstance(signature_data, str) else json.dumps(signature_data) + ) + + signed = False + for row in doc.signers: + if row.email == user and not row.executed_at: + row.signature = signature_value + row.executed_at = now_datetime() + signed = True + break + + if not signed: + frappe.throw(_("You are not authorized to sign this document.")) + + if doc.signers and all(row.executed_at for row in doc.signers): + doc.status = "Completed" + + doc.save(ignore_permissions=True) + return {"success": True} diff --git a/communications/www/electronic_signature/index.html b/communications/www/electronic_signature/index.html new file mode 100644 index 0000000..04d232d --- /dev/null +++ b/communications/www/electronic_signature/index.html @@ -0,0 +1,49 @@ +{% extends "templates/web.html" %} + +{% block title %}{{ title }}{% endblock %} + +{% block header %}

{{ title }}

{% endblock %} + +{% block page_content %} +
+ {% if guest %} +

{{ _("Please log in to see documents available for you to sign.") }}

+

{{ _("Log in") }}

+ {% elif signatures %} +
+ + + + + + + + + + + + + {% for row in signatures %} + + + + + + + + + {% endfor %} + +
{{ _("Document") }}{{ _("Title") }}{{ _("Status") }}{{ _("Reference") }}{{ _("Updated") }}{{ _("Action") }}
{{ row.name }}{{ row.title or "" }}{{ row.status or "" }}{% if row.reference_doctype and row.reference_name %}{{ row.reference_doctype }} · {{ row.reference_name }}{% else %}—{% endif %}{{ frappe.utils.format_datetime(row.modified) if row.modified else "" }} + {% if row.needs_signature %} + {{ _("Sign") }} + {% else %} + {{ _("View") }} + {% endif %} +
+
+ {% else %} +
{{ _("No documents are available for you to sign right now.") }}
+ {% endif %} +
+{% endblock %} diff --git a/communications/www/electronic_signature/index.py b/communications/www/electronic_signature/index.py new file mode 100644 index 0000000..d38233f --- /dev/null +++ b/communications/www/electronic_signature/index.py @@ -0,0 +1,67 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ + +from communications.www.electronic_signature.portal_paths import signature_portal_base + + +def get_context(context): + context.no_cache = 1 + context.title = _("Documents to sign") + context.signature_base = signature_portal_base() + context.guest = frappe.session.user == "Guest" + context.signatures = [] + + if context.guest: + return context + + user = frappe.session.user + user_type = frappe.db.get_value("User", user, "user_type") + + if user_type == "Website User": + contact = frappe.db.get_value("Contact", {"user": user}, "name") + if not contact: + return context + parent_names = frappe.get_all( + "Electronic Signature Signer", + filters={"contact": contact}, + pluck="parent", + distinct=True, + ) + if not parent_names: + return context + rows = frappe.get_all( + "Electronic Signature", + filters={ + "name": ["in", list(parent_names)], + "status": ["in", ["Out for Signature", "Completed"]], + }, + fields=["name", "title", "status", "reference_doctype", "reference_name", "modified"], + order_by="modified desc", + ) + print(rows) + for row in rows: + row["needs_signature"] = signer_row_needs_signature(row.name, contact) + context.signatures = rows + return context + + context.signatures = frappe.get_list( + "Electronic Signature", + filters={}, + fields=["name", "title", "status", "reference_doctype", "reference_name", "modified"], + order_by="modified desc", + ) + for row in context.signatures: + row["needs_signature"] = False + return context + + +def signer_row_needs_signature(parent, contact): + signer_rows = frappe.get_all( + "Electronic Signature Signer", + filters={"parent": parent, "contact": contact}, + fields=["executed_at"], + ) + return any(not r.executed_at for r in signer_rows) diff --git a/communications/www/electronic_signature/portal_paths.py b/communications/www/electronic_signature/portal_paths.py new file mode 100644 index 0000000..69b2786 --- /dev/null +++ b/communications/www/electronic_signature/portal_paths.py @@ -0,0 +1,12 @@ +# Copyright (c) 2026, AgriTheory and contributors +# For license information, please see license.txt + +import frappe + + +def signature_portal_base(): + """Public URL prefix for signing routes (matches website_route_rules).""" + path = (getattr(frappe.local, "path", None) or "").strip("/") + if path == "electronic_signature" or path.startswith("electronic_signature/"): + return "/electronic_signature" + return "/sign" From fa7e22f664b2c7af61b077b3134a5a4a4e089ad6 Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Thu, 23 Apr 2026 08:23:34 -0400 Subject: [PATCH 02/13] wip: better electronic signature --- .pre-commit-config.yaml | 2 +- .../electronic_signature.json | 18 +++++++- .../electronic_signature.py | 29 +++++++++--- .../communications/notifications.py | 7 ++- .../electronic_signature/[name]/index.html | 46 ++++++++++++++++--- .../www/electronic_signature/index.py | 1 - 6 files changed, 87 insertions(+), 16 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2d5e5dd..871c435 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,7 +45,7 @@ repos: additional_dependencies: ['flake8-bugbear'] - repo: https://github.com/agritheory/test_utils - rev: v1.22.0 + rev: v1.23.0 hooks: - id: update_pre_commit_config - id: validate_frappe_project diff --git a/communications/communications/doctype/electronic_signature/electronic_signature.json b/communications/communications/doctype/electronic_signature/electronic_signature.json index bbe7fd3..b8273ba 100644 --- a/communications/communications/doctype/electronic_signature/electronic_signature.json +++ b/communications/communications/doctype/electronic_signature/electronic_signature.json @@ -13,6 +13,8 @@ "reference_doctype", "reference_name", "print_format", + "letter_head", + "attach_signed_pdf_to_reference", "intro_section", "introduction", "signers_section", @@ -87,12 +89,26 @@ "fieldtype": "Link", "label": "Print Format", "options": "Print Format" + }, + { + "description": "Letter head used when rendering the reference document (portal preview, unsigned PDF/print, and content wrapped in the signed PDF). Leave blank to use the reference document\u2019s Letter Head field or the default Letter Head from Print Settings.", + "fieldname": "letter_head", + "fieldtype": "Link", + "label": "Letter Head", + "options": "Letter Head" + }, + { + "default": "1", + "description": "When the request is Completed, render the signed record to PDF and attach it to the reference document (for example a Task).", + "fieldname": "attach_signed_pdf_to_reference", + "fieldtype": "Check", + "label": "Attach signed PDF to reference" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2026-04-16 15:24:01.144254", + "modified": "2026-04-22 12:00:00.000000", "modified_by": "Administrator", "module": "Communications", "name": "Electronic Signature", diff --git a/communications/communications/doctype/electronic_signature/electronic_signature.py b/communications/communications/doctype/electronic_signature/electronic_signature.py index 3d41ba6..13fe6f2 100644 --- a/communications/communications/doctype/electronic_signature/electronic_signature.py +++ b/communications/communications/doctype/electronic_signature/electronic_signature.py @@ -32,6 +32,21 @@ def agent_debug_log(hypothesis_id, location, message, data=None): # #endregion +def strip_embedded_print_toolbar_html(html: str) -> str: + """Remove Frappe printview toolbar (Print / Get PDF) from HTML embedded on the website.""" + if not html or "action-banner" not in html: + return html + try: + from bs4 import BeautifulSoup + + soup = BeautifulSoup(html, "html.parser") + for node in soup.select(".action-banner"): + node.decompose() + return str(soup) + except Exception: + return html + + def website_portal_signer_may_read(doc, user): """Website User may read this doc on the portal when they are a listed signer (contact match).""" if not doc or not user or user == "Guest": @@ -58,12 +73,14 @@ def document_html(self): return "" try: ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name) - return frappe.get_print( - self.reference_doctype, - self.reference_name, - print_format=self.print_format or None, - doc=ref_doc, - ) + kwargs = { + "print_format": self.print_format or None, + "doc": ref_doc, + } + if frappe.db.has_column("Electronic Signature", "letter_head") and self.get("letter_head"): + kwargs["letterhead"] = self.letter_head + raw = frappe.get_print(self.reference_doctype, self.reference_name, **kwargs) + return strip_embedded_print_toolbar_html(raw) except Exception: frappe.log_error( title="Electronic Signature document_html print render", diff --git a/communications/communications/notifications.py b/communications/communications/notifications.py index 711caee..f8cd6cf 100644 --- a/communications/communications/notifications.py +++ b/communications/communications/notifications.py @@ -206,7 +206,12 @@ def send_event_notification( attachments=attachments, reference_doctype="Event", reference_name=event.name, - now=True, + # In normal/dev usage we want an Email Queue row (no SMTP required) so support + # can inspect outgoing notifications without a mail server. + # In tests, frappe.flags.in_test is True and Email Queue send() is suppressed + # unless frappe.flags.testing_email is also True; sending immediately keeps + # existing test expectations intact when they explicitly enable it. + now=bool(getattr(frappe.flags, "in_test", False)), ) except OutgoingEmailError: frappe.log_error( diff --git a/communications/www/electronic_signature/[name]/index.html b/communications/www/electronic_signature/[name]/index.html index 89519c5..caf04e8 100644 --- a/communications/www/electronic_signature/[name]/index.html +++ b/communications/www/electronic_signature/[name]/index.html @@ -6,10 +6,10 @@ {% block page_content %}
-
-
+
+
-
+
{% if electronic_signature.introduction %}
{{ electronic_signature.introduction }} @@ -18,9 +18,10 @@ {% endif %} {% if electronic_signature.document_html %}
-

{{ _("Document") }}

-
- {{ electronic_signature.document_html | safe }} +
+
+ {{ electronic_signature.document_html | safe }} +

@@ -163,6 +164,39 @@

{{ _("Signatures") }}

margin-bottom: 2rem; } + /* One column for heading + embed: horizontal rhythm matches print preview gutter (Frappe standard.css). */ + .es-document-print-frame { + box-sizing: border-box; + width: 100%; + max-width: 100%; + margin-left: auto; + margin-right: auto; + padding-left: 0.75rem; + padding-right: 0.75rem; + } + + @media (min-width: 768px) { + .es-document-print-frame { + padding-left: 1.25rem; + padding-right: 1.25rem; + } + } + + @media (min-width: 992px) { + .es-document-print-frame { + padding-left: 1.5rem; + padding-right: 1.5rem; + } + } + + .document-html.es-document-html .print-format-gutter { + box-sizing: border-box; + } + + .document-html.es-document-html .action-banner { + display: none !important; + } + .signature-pad { border: 1px dashed #ccc; border-radius: 5px; diff --git a/communications/www/electronic_signature/index.py b/communications/www/electronic_signature/index.py index d38233f..1b727e1 100644 --- a/communications/www/electronic_signature/index.py +++ b/communications/www/electronic_signature/index.py @@ -41,7 +41,6 @@ def get_context(context): fields=["name", "title", "status", "reference_doctype", "reference_name", "modified"], order_by="modified desc", ) - print(rows) for row in rows: row["needs_signature"] = signer_row_needs_signature(row.name, contact) context.signatures = rows From 57eaccbf00fbf337e443ea3be961c5e5b6c3fb03 Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Thu, 23 Apr 2026 16:10:29 -0400 Subject: [PATCH 03/13] fix: remove stubbed file that mypy was complaingin about --- communications/www/electronic_signature/[name]/__init__.py | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 communications/www/electronic_signature/[name]/__init__.py diff --git a/communications/www/electronic_signature/[name]/__init__.py b/communications/www/electronic_signature/[name]/__init__.py deleted file mode 100644 index ea2ab3f..0000000 --- a/communications/www/electronic_signature/[name]/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) 2026, AgriTheory and contributors -# For license information, please see license.txt From cb664f4c3c1d80852126dd316a9da3908d701f88 Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Thu, 7 May 2026 10:14:36 -0400 Subject: [PATCH 04/13] fix: electronic signature listview, docs and links --- .pre-commit-config.yaml | 2 +- README.md | 1 + .../electronic_signature.js | 45 +++++++++++++++-- .../electronic_signature.json | 10 +++- .../electronic_signature.py | 10 +++- .../electronic_signature_list.js | 6 +++ communications/hooks.py | 10 ++-- .../public/js/electronic_signature.js | 45 ----------------- .../public/js/public_calendar.bundle.js | 2 +- communications/public/js/public_calendar.js | 2 +- .../tests/test_electronic_signature.py | 23 +++++++++ docs/electronic_signature.md | 49 +++++++++++++++++++ 12 files changed, 144 insertions(+), 61 deletions(-) create mode 100644 communications/communications/doctype/electronic_signature/electronic_signature_list.js delete mode 100644 communications/public/js/electronic_signature.js create mode 100644 docs/electronic_signature.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 871c435..b2a85f3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,7 +45,7 @@ repos: additional_dependencies: ['flake8-bugbear'] - repo: https://github.com/agritheory/test_utils - rev: v1.23.0 + rev: v1.25.0 hooks: - id: update_pre_commit_config - id: validate_frappe_project diff --git a/README.md b/README.md index 41c8bd2..5f089b4 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Messaging and telephony extensions for Frappe - [Public calendar, scheduling, RSVP, notifications](docs/calendar.md) - [Video conferencing, Appointment Settings, Zoom / Google Meet](docs/video-conferencing.md) - [Slack/Teams Notification channels, assignment patch, phone helpers](docs/integrations.md) +- [Electronic Signature: desk connections and per-site customization](docs/electronic_signature.md) #### License diff --git a/communications/communications/doctype/electronic_signature/electronic_signature.js b/communications/communications/doctype/electronic_signature/electronic_signature.js index 2e034d6..d912132 100644 --- a/communications/communications/doctype/electronic_signature/electronic_signature.js +++ b/communications/communications/doctype/electronic_signature/electronic_signature.js @@ -1,8 +1,45 @@ // Copyright (c) 2026, AgriTheory and contributors // For license information, please see license.txt -// frappe.ui.form.on("Electronic Signature", { -// refresh(frm) { +frappe.ui.form.on('Electronic Signature', { + onload_post_render(frm) { + if (['Draft', 'Out for Signature'].includes(frm.doc.status)) { + add_send_signature_invitations_button(frm) + } + }, + refresh(frm) { + if (['Draft', 'Out for Signature'].includes(frm.doc.status)) { + add_send_signature_invitations_button(frm) + } + }, +}) -// }, -// }); +function add_send_signature_invitations_button(frm) { + frm.page.add_action_item(__('Send signature invitations'), () => { + if (!frm.doc.signers || !frm.doc.signers.length) { + frappe.msgprint(__('Add at least one signer first.')) + return + } + frm.doc.signers.forEach(row => { + const recipient = row.email + if (!recipient) { + return + } + frappe + .xcall('communications.communications.signatures.fetch_signature_invitation_email', { + doc: frm.doc, + recipient: recipient, + }) + .then(r => { + return new frappe.views.CommunicationComposer({ + doc: frm.doc, + frm: frm, + subject: r.subject || __('Signature requested'), + recipients: r.recipients, + attach_document_print: false, + message: r.email_message || '', + }) + }) + }) + }) +} diff --git a/communications/communications/doctype/electronic_signature/electronic_signature.json b/communications/communications/doctype/electronic_signature/electronic_signature.json index b8273ba..3494bc5 100644 --- a/communications/communications/doctype/electronic_signature/electronic_signature.json +++ b/communications/communications/doctype/electronic_signature/electronic_signature.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_import": 1, "allow_rename": 1, "autoname": "naming_series:", "creation": "2026-04-16 12:00:00", @@ -51,12 +52,16 @@ { "fieldname": "reference_doctype", "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, "label": "Reference DocType", "options": "DocType" }, { "fieldname": "reference_name", "fieldtype": "Dynamic Link", + "in_list_view": 1, + "in_standard_filter": 1, "label": "Reference Name", "options": "reference_doctype" }, @@ -87,6 +92,7 @@ { "fieldname": "print_format", "fieldtype": "Link", + "in_list_view": 1, "label": "Print Format", "options": "Print Format" }, @@ -106,9 +112,8 @@ } ], "grid_page_length": 50, - "index_web_pages_for_search": 1, "links": [], - "modified": "2026-04-22 12:00:00.000000", + "modified": "2026-05-07 09:46:46.137864", "modified_by": "Administrator", "module": "Communications", "name": "Electronic Signature", @@ -156,5 +161,6 @@ "sort_field": "modified", "sort_order": "DESC", "states": [], + "title_field": "title", "track_changes": 1 } \ No newline at end of file diff --git a/communications/communications/doctype/electronic_signature/electronic_signature.py b/communications/communications/doctype/electronic_signature/electronic_signature.py index 13fe6f2..31044aa 100644 --- a/communications/communications/doctype/electronic_signature/electronic_signature.py +++ b/communications/communications/doctype/electronic_signature/electronic_signature.py @@ -79,7 +79,15 @@ def document_html(self): } if frappe.db.has_column("Electronic Signature", "letter_head") and self.get("letter_head"): kwargs["letterhead"] = self.letter_head - raw = frappe.get_print(self.reference_doctype, self.reference_name, **kwargs) + # Portal signers may read the Electronic Signature but not the referenced document. + # frappe.get_print -> printview validates print permission on the reference doc under + # the current session user, which would hide the agreement body for external signers. + prev_ignore_print = bool(getattr(frappe.flags, "ignore_print_permissions", None)) + frappe.flags.ignore_print_permissions = True + try: + raw = frappe.get_print(self.reference_doctype, self.reference_name, **kwargs) + finally: + frappe.flags.ignore_print_permissions = prev_ignore_print return strip_embedded_print_toolbar_html(raw) except Exception: frappe.log_error( diff --git a/communications/communications/doctype/electronic_signature/electronic_signature_list.js b/communications/communications/doctype/electronic_signature/electronic_signature_list.js new file mode 100644 index 0000000..6079f1b --- /dev/null +++ b/communications/communications/doctype/electronic_signature/electronic_signature_list.js @@ -0,0 +1,6 @@ +// Copyright (c) 2026, AgriTheory and contributors +// For license information, please see license.txt + +frappe.listview_settings['Electronic Signature'] = { + hide_name_column: true, +} diff --git a/communications/hooks.py b/communications/hooks.py index f732785..e6c8dbc 100644 --- a/communications/hooks.py +++ b/communications/hooks.py @@ -20,9 +20,11 @@ # } # ] -# app_include_css = "/assets/communications/css/communications.css" +# Desk JS: there is no communications.bundle.js entry. Phone ControlData + formatter ship as +# public/js/teams.bundle.js (esbuild discovers *.bundle.js under public/). Website calendar uses +# web_include_js below, not app_include_js. app_include_js = [ - "communications.bundle.js", + "teams.bundle.js", ] app_include_css = ["/assets/communications/css/public_calendar.css"] @@ -97,10 +99,6 @@ {"from_route": "/sign", "to_route": "electronic_signature"}, ] -doctype_js = { - "Electronic Signature": "public/js/electronic_signature.js", -} - doc_events = { "Event": { "validate": "communications.communications.overrides.event.validate", diff --git a/communications/public/js/electronic_signature.js b/communications/public/js/electronic_signature.js deleted file mode 100644 index d912132..0000000 --- a/communications/public/js/electronic_signature.js +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) 2026, AgriTheory and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Electronic Signature', { - onload_post_render(frm) { - if (['Draft', 'Out for Signature'].includes(frm.doc.status)) { - add_send_signature_invitations_button(frm) - } - }, - refresh(frm) { - if (['Draft', 'Out for Signature'].includes(frm.doc.status)) { - add_send_signature_invitations_button(frm) - } - }, -}) - -function add_send_signature_invitations_button(frm) { - frm.page.add_action_item(__('Send signature invitations'), () => { - if (!frm.doc.signers || !frm.doc.signers.length) { - frappe.msgprint(__('Add at least one signer first.')) - return - } - frm.doc.signers.forEach(row => { - const recipient = row.email - if (!recipient) { - return - } - frappe - .xcall('communications.communications.signatures.fetch_signature_invitation_email', { - doc: frm.doc, - recipient: recipient, - }) - .then(r => { - return new frappe.views.CommunicationComposer({ - doc: frm.doc, - frm: frm, - subject: r.subject || __('Signature requested'), - recipients: r.recipients, - attach_document_print: false, - message: r.email_message || '', - }) - }) - }) - }) -} diff --git a/communications/public/js/public_calendar.bundle.js b/communications/public/js/public_calendar.bundle.js index 3a05a3c..43badd4 100644 --- a/communications/public/js/public_calendar.bundle.js +++ b/communications/public/js/public_calendar.bundle.js @@ -1,4 +1,4 @@ // Copyright (c) 2025, AgriTheory and contributors // For license information, please see license.txt -frappe.require(['/assets/communications/js/public_calendar.js']) +import './public_calendar.js' diff --git a/communications/public/js/public_calendar.js b/communications/public/js/public_calendar.js index 8cb16b9..82be10b 100644 --- a/communications/public/js/public_calendar.js +++ b/communications/public/js/public_calendar.js @@ -1,7 +1,7 @@ // Copyright (c) 2025, AgriTheory and contributors // For license information, please see license.txt -frappe.provide('public_calendar') +const public_calendar = frappe.provide('public_calendar') public_calendar.Calendar = class Calendar { constructor(wrapper, options) { diff --git a/communications/tests/test_electronic_signature.py b/communications/tests/test_electronic_signature.py index 9d82ed4..59be863 100644 --- a/communications/tests/test_electronic_signature.py +++ b/communications/tests/test_electronic_signature.py @@ -109,6 +109,29 @@ def test_has_permission_denies_non_signer_customer(signature_email_template): assert has_electronic_signature_permission(es, user=other.name, ptype="read") is False +def test_document_html_renders_for_signer_without_reference_doc_perm(signature_email_template): + """Signers must see embedded print HTML even when they cannot read the referenced doc.""" + frappe.set_user("Administrator") + note = frappe.new_doc("Note") + note.title = "Agreement body" + note.public = 0 + note.insert() + + es, email, contact_name = make_electronic_signature_with_signers() + es.reference_doctype = "Note" + es.reference_name = note.name + es.save(ignore_permissions=True) + + frappe.set_user(email) + doc = frappe.get_doc("Electronic Signature", es.name) + assert doc.has_permission("read") is True + assert frappe.has_permission("Note", "read", doc=frappe.get_doc("Note", note.name)) is False + html = doc.document_html + assert html and "print-format-gutter" in html + + frappe.set_user("Administrator") + + def test_fetch_signature_invitation_email(signature_email_template): es, email, contact_name = make_electronic_signature_with_signers("Draft") frappe.set_user("Administrator") diff --git a/docs/electronic_signature.md b/docs/electronic_signature.md new file mode 100644 index 0000000..d096c5f --- /dev/null +++ b/docs/electronic_signature.md @@ -0,0 +1,49 @@ + + +# Electronic Signature and desk connections + +**Electronic Signature** can reference any document via **Reference DocType** and **Reference Name** (a Dynamic Link). The Communications app does **not** ship hard-coded links from specific doctypes (for example **Task**) to **Electronic Signature**, because each site chooses which business objects participate in signing. + +## Recommended: Document Links (customization) + +Add a **Document Link** on each doctype where users should see related signatures in the **Connections** tab. + +1. Open **Customize Form** (or **DocType** → **Task**, if you customize in developer mode). +2. Select the target DocType (e.g. **Task**). +3. In **Document Links**, add a row: + - **Link DocType**: `Electronic Signature` + - **Link Fieldname**: `reference_name` (this is the Dynamic Link field on **Electronic Signature**) + - **Group**: any label you want in the connections sidebar (e.g. `Signatures`) + +Export customizations to your app (with **Export Customization** / `sync_on_migrate`) so the link is version-controlled. Lucent ERP includes an example for **Task** in `lucent_erp/lucent_erp/custom/task.json` under the `links` key. That row is applied on **`bench migrate`** only if the **Electronic Signature** DocType exists (Communications installed on the site); otherwise skip or install Communications first. + +If your bench cannot sync a `links` row from JSON (for example validation differences), add the Document Link once in **Customize Form** and re-export so the file includes the generated **`name`** and any **`links_order`** property setter Frappe creates. + +### Dynamic Link caveat + +Document Links drive the **Connections** UI and set the dashboard’s `non_standard_fieldnames`. For a **Dynamic Link**, connection **counts** in the desk are most accurate when the dashboard also knows which doctype the reference points to (`reference_doctype` for **Electronic Signature**). Frappe’s `DocType Link` rows do **not** configure that `dynamic_links` map. + +In practice: + +- If **Reference Name** values are unique per referenced doctype (typical for ERPNext naming), filtering by `reference_name` alone is often enough. +- If you need guaranteed-correct filters or counts, use one of the alternatives below. + +## Alternative A: dedicated Link field (customization) + +Add a **Link** field on **Electronic Signature** that points at the doctype you care about (e.g. **Link** to **Task** named `task`). Keep it in sync with **Reference DocType** / **Reference Name** via client script, workflow, or server script as needed. + +Then add a **Document Link** on **Task** using **`link_fieldname` = `task`** (the static Link field). Dashboard filtering uses a single field and stays correct without extra Python. + +## Alternative B: dashboard hook (code) + +For a site that wants to keep only the Dynamic Link fields and still set `dynamic_links`, extend **`override_doctype_dashboards`** for the parent doctype—for example merge into the dict returned for **Task**: + +- `non_standard_fieldnames["Electronic Signature"] = "reference_name"` +- `dynamic_links["reference_name"] = ["Task", "reference_doctype"]` + +This is optional and belongs in the customer or vertical app (not Communications). + +## Related + +- [Desk integrations (notifications, etc.)](./integrations.md) From 8dc0442431ecab7784794bb3e55a8b294610bd36 Mon Sep 17 00:00:00 2001 From: Heather Kusmierz Date: Fri, 12 Jun 2026 15:14:49 -0400 Subject: [PATCH 05/13] test: CI should fail on test failure, fix conftest dir --- .github/workflows/pytest.yaml | 104 ------------------------------- .github/workflows/pytest.yml | 1 + communications/hooks.py | 2 +- communications/tests/conftest.py | 2 + 4 files changed, 4 insertions(+), 105 deletions(-) delete mode 100644 .github/workflows/pytest.yaml diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml deleted file mode 100644 index 68ad5fb..0000000 --- a/.github/workflows/pytest.yaml +++ /dev/null @@ -1,104 +0,0 @@ -name: Tests - -permissions: - contents: read - pull-requests: write - -on: - push: - branches: [version-14, version-15] - pull_request: - branches: [version-14, version-15] - -env: - BRANCH_NAME: ${{ github.base_ref || github.ref_name }} - -jobs: - tests: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest] - fail-fast: false - name: Server - - services: - mariadb: - image: mariadb:10.6 - env: - MYSQL_ALLOW_EMPTY_PASSWORD: YES - MYSQL_ROOT_PASSWORD: 'admin' - ports: - - 3306:3306 - options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 - - steps: - - name: Clone - uses: actions/checkout@v4 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 20 - check-latest: true - cache: 'yarn' - - - name: Add to Hosts - run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts - - - name: Cache pip - uses: actions/cache@v4 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml', '**/setup.py') }} - restore-keys: | - ${{ runner.os }}-pip- - ${{ runner.os }}- - - - name: Cache node modules - uses: actions/cache@v4 - env: - cache-name: cache-node-modules - with: - path: ~/.npm - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}- - ${{ runner.os }}-build- - ${{ runner.os }}- - - - name: Install Poetry - uses: snok/install-poetry@v1 - - - name: Install JS Dependencies - run: yarn --prefer-offline - - - name: Install App Dependencies - run: bash ${{ github.workspace }}/.github/helper/install_dependencies.sh - - - name: Install Bench Site and Apps - env: - MYSQL_HOST: 'localhost' - MYSQL_PWD: 'admin' - BRANCH_NAME: ${{ env.BRANCH_NAME}} - run: | - bash ${{ github.workspace }}/.github/helper/install.sh - - - name: Run Tests - working-directory: /home/runner/frappe-bench - run: | - source env/bin/activate - cd apps/communications - poetry install - pytest --cov=communications --cov-report=xml --disable-warnings -s | tee pytest-coverage.txt - - - name: Pytest coverage comment - uses: MishaKav/pytest-coverage-comment@main - with: - pytest-coverage-path: /home/runner/frappe-bench/apps/communications/pytest-coverage.txt - junitxml-path: /home/runner/frappe-bench/apps/communications/coverage.xml \ No newline at end of file diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index ca9cc6d..046c1b9 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -80,6 +80,7 @@ jobs: - name: Run Tests working-directory: /home/runner/frappe-bench run: | + set -o pipefail source env/bin/activate cd apps/communications pytest --cov=communications --cov-report=xml -s | tee pytest-coverage.txt diff --git a/communications/hooks.py b/communications/hooks.py index 297fcd7..8d12d6c 100644 --- a/communications/hooks.py +++ b/communications/hooks.py @@ -8,7 +8,7 @@ app_email = "support@agritheory.dev" app_license = "mit" -# required_apps = [] +required_apps = ["erpnext", "hrms"] # add_to_apps_screen = [ # { diff --git a/communications/tests/conftest.py b/communications/tests/conftest.py index f3f7866..361df58 100644 --- a/communications/tests/conftest.py +++ b/communications/tests/conftest.py @@ -2,6 +2,7 @@ # For license information, please see license.txt import json +import os from pathlib import Path from unittest.mock import MagicMock @@ -39,6 +40,7 @@ def db_instance(): if (sites / "common_site_config.json").is_file(): currentsite = json.loads((sites / "common_site_config.json").read_text()).get("default_site") + os.chdir(sites) frappe.init(site=currentsite, sites_path=sites) frappe.connect() frappe.db.commit = MagicMock() From ab885a9a7313ed64e6acc8a331fe13209a8a212a Mon Sep 17 00:00:00 2001 From: Heather Kusmierz Date: Fri, 12 Jun 2026 16:15:28 -0400 Subject: [PATCH 06/13] ci: add assets.json, needed in esig render test --- .github/helper/install.sh | 6 ++++++ .github/workflows/pytest.yml | 1 + 2 files changed, 7 insertions(+) diff --git a/.github/helper/install.sh b/.github/helper/install.sh index 3010d27..3b9f624 100755 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -53,6 +53,12 @@ bench start &> bench_run_logs.txt & CI=Yes & bench --site test_site reinstall --yes --admin-password admin +# Create a minimal assets manifest so that frappe.get_print can render +# printview.html (which calls include_style → get_assets_json) without +# crashing when the bench was initialised with --skip-assets. +mkdir -p ~/frappe-bench/sites/assets +echo '{}' > ~/frappe-bench/sites/assets/assets.json + bench setup requirements --dev echo "BENCH VERSION NUMBERS:" diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 046c1b9..ea2c588 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -83,6 +83,7 @@ jobs: set -o pipefail source env/bin/activate cd apps/communications + poetry install pytest --cov=communications --cov-report=xml -s | tee pytest-coverage.txt - name: Pytest coverage comment From deec51edb5858c20eeae86795710046c0e5ae80b Mon Sep 17 00:00:00 2001 From: Heather Kusmierz Date: Fri, 12 Jun 2026 16:36:25 -0400 Subject: [PATCH 07/13] ci: install poetry --- .github/workflows/pytest.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index ea2c588..e03cd9b 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -69,6 +69,9 @@ jobs: - name: Install App Dependencies run: bash ${{ github.workspace }}/.github/helper/install_dependencies.sh + - name: Install Poetry + uses: snok/install-poetry@v1 + - name: Install Bench Site and Apps env: MYSQL_HOST: 'localhost' From e5fc715027ba86c595e89d91a8b52902ec5e33c0 Mon Sep 17 00:00:00 2001 From: Heather Kusmierz Date: Sat, 13 Jun 2026 10:41:26 -0400 Subject: [PATCH 08/13] docs: remove client reference, add other config --- docs/electronic_signature.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/electronic_signature.md b/docs/electronic_signature.md index d096c5f..57cb059 100644 --- a/docs/electronic_signature.md +++ b/docs/electronic_signature.md @@ -3,6 +3,11 @@ For license information, please see license.txt--> # Electronic Signature and desk connections + + + **Electronic Signature** can reference any document via **Reference DocType** and **Reference Name** (a Dynamic Link). The Communications app does **not** ship hard-coded links from specific doctypes (for example **Task**) to **Electronic Signature**, because each site chooses which business objects participate in signing. ## Recommended: Document Links (customization) @@ -15,8 +20,7 @@ Add a **Document Link** on each doctype where users should see related signature - **Link DocType**: `Electronic Signature` - **Link Fieldname**: `reference_name` (this is the Dynamic Link field on **Electronic Signature**) - **Group**: any label you want in the connections sidebar (e.g. `Signatures`) - -Export customizations to your app (with **Export Customization** / `sync_on_migrate`) so the link is version-controlled. Lucent ERP includes an example for **Task** in `lucent_erp/lucent_erp/custom/task.json` under the `links` key. That row is applied on **`bench migrate`** only if the **Electronic Signature** DocType exists (Communications installed on the site); otherwise skip or install Communications first. +4. Export customizations to your app (with **Export Customization** / `sync_on_migrate`) so the link is version-controlled. If your bench cannot sync a `links` row from JSON (for example validation differences), add the Document Link once in **Customize Form** and re-export so the file includes the generated **`name`** and any **`links_order`** property setter Frappe creates. @@ -47,3 +51,9 @@ This is optional and belongs in the customer or vertical app (not Communications ## Related - [Desk integrations (notifications, etc.)](./integrations.md) + +## Other Configuration Notes + +A "Send for Signatures" action is available when the Electronic Signature document is in "Draft" or "Out for Signatures" status. This action creates an email composer for each signer in the Signatures table, including a magic link to automatically log in and access the document in the email body. At this point, the document should be set to "Out for Signatures" status, as a portal user won't be able to see a document in "Draft" status. From here, they can view and execute the document, as rendered by the print format. + +Once all users in the Signatures section have executed the document, the status automatically changes to "Completed". From 512b7680384e21dbf9337ffcc92cd080d87021ab Mon Sep 17 00:00:00 2001 From: Heather Kusmierz Date: Sat, 13 Jun 2026 12:11:09 -0400 Subject: [PATCH 09/13] test: set in_test flag --- communications/tests/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/communications/tests/conftest.py b/communications/tests/conftest.py index 361df58..997c5f4 100644 --- a/communications/tests/conftest.py +++ b/communications/tests/conftest.py @@ -43,5 +43,6 @@ def db_instance(): os.chdir(sites) frappe.init(site=currentsite, sites_path=sites) frappe.connect() + frappe.flags.in_test = True frappe.db.commit = MagicMock() yield frappe.db From c867f21ce73bfdfbb947b6afd4eabdf38bb0114a Mon Sep 17 00:00:00 2001 From: Heather Kusmierz Date: Sat, 13 Jun 2026 12:48:42 -0400 Subject: [PATCH 10/13] docs: add email/portal config --- communications/communications/signatures.py | 2 +- docs/electronic_signature.md | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/communications/communications/signatures.py b/communications/communications/signatures.py index 1a8558e..36c559b 100644 --- a/communications/communications/signatures.py +++ b/communications/communications/signatures.py @@ -7,7 +7,7 @@ ``get_magic_link`` builds a one-time login URL when **Login with Email Link** is enabled in System Settings. For the link to land on the signing page after login, the site must honor the ``redirect-to`` query parameter on the login-key handler. Frappe core may not -apply that redirect; washmoreerp implements this by overriding +apply that redirect; one option to implement this is by overriding ``frappe.www.login.login_via_key``. Sites using the signing portal can copy that pattern into their own app if needed. """ diff --git a/docs/electronic_signature.md b/docs/electronic_signature.md index 57cb059..f3bdefd 100644 --- a/docs/electronic_signature.md +++ b/docs/electronic_signature.md @@ -4,7 +4,7 @@ For license information, please see license.txt--> # Electronic Signature and desk connections @@ -52,8 +52,14 @@ This is optional and belongs in the customer or vertical app (not Communications - [Desk integrations (notifications, etc.)](./integrations.md) -## Other Configuration Notes +## Email Signature Invitations and Portal Access Configuration -A "Send for Signatures" action is available when the Electronic Signature document is in "Draft" or "Out for Signatures" status. This action creates an email composer for each signer in the Signatures table, including a magic link to automatically log in and access the document in the email body. At this point, the document should be set to "Out for Signatures" status, as a portal user won't be able to see a document in "Draft" status. From here, they can view and execute the document, as rendered by the print format. +A "Send for Signatures" action is available when the Electronic Signature document is in "Draft" or "Out for Signatures" status. This action creates an email composer for each signer in the Signatures table. The composer expects an Email Template to exist with the name "Electronic Signature Request", which is created on installation of the Communications app and may be edited as-needed. + +The email body can include a one-time login magic link for the user to directly access the document in the email body. First, "Login with Email Link" must be enabled in System Settings. Second, the "MAGIC_LINK" text must be present in the body of the "Electronic Signature Request" Email Template for the functionality to work. For the link to land on the signing page after login, the site must honor the `redirect-to` query parameter on the login-key handler. Frappe core may not apply that redirect; one option to implement this is by overriding `frappe.www.login.login_via_key`. + +At this point, the document should be set to "Out for Signatures" status, as a portal user won't be able to see a document in "Draft" status. From here, they can view and execute the document, as rendered by the print format. + +To have a dedicated menu item in the Portal sidebar for users to see the "Sign" endpoint, that may be added in "Portal Settings" in the "Custom Sidebar Menu" table. Once all users in the Signatures section have executed the document, the status automatically changes to "Completed". From 0b7659cf29f5edaa7d654392db4d737d43eb1738 Mon Sep 17 00:00:00 2001 From: Heather Kusmierz Date: Sat, 13 Jun 2026 13:06:07 -0400 Subject: [PATCH 11/13] test: add notification document_type to avoid redis cache error --- communications/tests/test_notification_scheduler.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/communications/tests/test_notification_scheduler.py b/communications/tests/test_notification_scheduler.py index 2895f05..dd63972 100644 --- a/communications/tests/test_notification_scheduler.py +++ b/communications/tests/test_notification_scheduler.py @@ -352,6 +352,7 @@ def test_digest_routed_through_email_override(): "subject": "Test Override", "message": "{{ sendmail_message }}", "channel": "Email", + "document_type": "Event", "enabled": 1, "email_override": "Mention, Assignment, Share, Energy Point, Alert", } @@ -401,6 +402,7 @@ def test_individual_notification_routed_through_email_override(): "subject": "Test Override", "message": "{{ sendmail_subject }}", "channel": "Email", + "document_type": "Event", "enabled": 1, "email_override": "Mention, Assignment, Share, Energy Point, Alert", } From 80d742b693fa2d05e78652a1855eb554fa406287 Mon Sep 17 00:00:00 2001 From: Heather Kusmierz Date: Sat, 13 Jun 2026 19:46:11 -0400 Subject: [PATCH 12/13] test: use patch for consistent sendmail behavior w or wout smtp setup --- .../tests/test_notification_scheduler.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/communications/tests/test_notification_scheduler.py b/communications/tests/test_notification_scheduler.py index dd63972..4d9058a 100644 --- a/communications/tests/test_notification_scheduler.py +++ b/communications/tests/test_notification_scheduler.py @@ -148,14 +148,15 @@ def test_priority_doctype_bypasses_batching(): config.save() task = frappe.get_doc({"doctype": "Task", "subject": "Test Task for Priority"}).insert() - assign_user( - { - "doctype": "Task", - "name": task.name, - "assign_to": [TEST_USER], - "description": "Test assignment", - } - ) + with patch("frappe.sendmail", side_effect=Exception("No mail server configured")): + assign_user( + { + "doctype": "Task", + "name": task.name, + "assign_to": [TEST_USER], + "description": "Test assignment", + } + ) queue_entries = frappe.get_all( "Assignment Notification Queue", filters={"reference_name": task.name} From 8b5b2337b4d32784b35c4947c9b84ae3cba1c8a4 Mon Sep 17 00:00:00 2001 From: Heather Kusmierz Date: Tue, 16 Jun 2026 10:56:57 -0400 Subject: [PATCH 13/13] test: add patches so code reaches tested except/error log blocks --- communications/tests/test_notification_scheduler.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/communications/tests/test_notification_scheduler.py b/communications/tests/test_notification_scheduler.py index 4d9058a..ef06475 100644 --- a/communications/tests/test_notification_scheduler.py +++ b/communications/tests/test_notification_scheduler.py @@ -141,6 +141,10 @@ def test_multiple_assignments_share_window_key(): def test_priority_doctype_bypasses_batching(): + """Priority doctypes bypass batching: the queue entry is created with bypass_batching=1 + and send_individual_notification is called immediately. When the send fails the status + must be 'Failed' and an error must be logged — regardless of whether a mail server is + configured in site_config.""" config = frappe.get_single("Notification Window Settings") config.enabled = 1 config.bypass_batching_for_priority = 1 @@ -148,7 +152,13 @@ def test_priority_doctype_bypasses_batching(): config.save() task = frappe.get_doc({"doctype": "Task", "subject": "Test Task for Priority"}).insert() - with patch("frappe.sendmail", side_effect=Exception("No mail server configured")): + + # Patch frappe.sendmail to raise and try_email_override to return False so the full + # dispatcher failure-handling path runs (status -> "Failed", error logged) regardless + # of whether a real mail server is configured in site_config. + with patch("frappe.sendmail", side_effect=Exception("Simulated send failure")), patch( + "communications.notification_scheduler.dispatcher.try_email_override", return_value=False + ): assign_user( { "doctype": "Task",