diff --git a/sign_oca_order/README.rst b/sign_oca_order/README.rst new file mode 100644 index 00000000..f52a8e5f --- /dev/null +++ b/sign_oca_order/README.rst @@ -0,0 +1,120 @@ +================================ +Sign OCA Sequential Signing & CC +================================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:893e36eb59a523f3a6fdf6819ef488de05a109536eaf1803313ce7ba14b6a026 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsign-lightgray.png?logo=github + :target: https://github.com/OCA/sign/tree/18.0/sign_oca_order + :alt: OCA/sign +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/sign-18-0/sign-18-0-sign_oca_order + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/sign&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module extends OCA Sign with sequential (ordered) signing and CC +recipient functionality, similar to DocuSign's routing and carbon copy +features. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +No special configuration needed. The module adds new fields to the sign +request form: + +- **Signing Mode**: Choose "Parallel" (all at once, default) or + "Sequential" (ordered) +- **CC Recipients**: Partners who receive a copy of the signed document + after completion + +When using sequential mode, set the signing order on each signer line. +Signers with the same order number sign simultaneously within that step. + +Usage +===== + +**Sequential Signing:** + +1. Create a new sign request +2. Set Signing Mode to "Sequential" +3. Add signers and set their Signing Order (1, 2, 3...) +4. Send the request -- only order 1 signers are notified +5. After order 1 completes, order 2 signers are automatically notified +6. Repeat until all steps complete + +**CC Recipients:** + +1. Add partners to the CC Recipients field on the sign request +2. After all signers complete, CC recipients receive an email with the + signed PDF attached + +**Parallel Groups:** + +Signers with the same signing order number sign simultaneously. For +example: + +- Order 1: Alice, Bob (both sign in parallel) +- Order 2: Carol (signs after Alice AND Bob complete) + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Keboola + +Contributors +------------ + +- `Keboola `__: + + - Jiri Manas + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/sign `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/sign_oca_order/__init__.py b/sign_oca_order/__init__.py new file mode 100644 index 00000000..3833fba9 --- /dev/null +++ b/sign_oca_order/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2025 Keboola +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import controllers +from . import models diff --git a/sign_oca_order/__manifest__.py b/sign_oca_order/__manifest__.py new file mode 100644 index 00000000..a84bdbf1 --- /dev/null +++ b/sign_oca_order/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2025 Keboola +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Sign OCA Sequential Signing & CC", + "version": "18.0.1.0.0", + "category": "Sign", + "summary": "Sequential/ordered signing with CC recipients for OCA Sign", + "author": "Keboola, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/sign", + "license": "AGPL-3", + "depends": ["sign_oca", "sign_oca_reminder"], + "development_status": "Beta", + "data": [ + "data/mail_template_data.xml", + "views/sign_oca_request_views.xml", + "views/portal_templates.xml", + ], + "installable": True, + "application": False, + "auto_install": False, +} diff --git a/sign_oca_order/controllers/__init__.py b/sign_oca_order/controllers/__init__.py new file mode 100644 index 00000000..23548d9b --- /dev/null +++ b/sign_oca_order/controllers/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Keboola +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import main diff --git a/sign_oca_order/controllers/main.py b/sign_oca_order/controllers/main.py new file mode 100644 index 00000000..29939016 --- /dev/null +++ b/sign_oca_order/controllers/main.py @@ -0,0 +1,72 @@ +# Copyright 2025 Keboola +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import http +from odoo.exceptions import AccessError, MissingError +from odoo.http import request + +from odoo.addons.sign_oca.controllers.main import PortalSign + + +class PortalSignOrder(PortalSign): + """Extend the portal sign controller to enforce sequential signing order. + + When a sign request uses ``signing_mode == 'sequential'``, signers whose + ``signer_state`` is still ``'waiting'`` are shown a "please wait" page + instead of the signing UI, and their JSON sign endpoint returns an error. + """ + + @http.route( + ["/sign_oca/document//"], + type="http", + auth="public", + website=True, + ) + def get_sign_oca_access(self, signer_id, access_token, **kwargs): + try: + signer_sudo = self._document_check_access( + "sign.oca.request.signer", signer_id, access_token + ) + except (AccessError, MissingError): + return request.redirect("/my") + + sign_request = signer_sudo.request_id + if ( + sign_request.signing_mode == "sequential" + and not signer_sudo.signed_on + and signer_sudo.signer_state == "waiting" + ): + return request.render( + "sign_oca_order.portal_sign_document_waiting", + { + "signer": signer_sudo, + "company": sign_request.company_id, + "request_name": sign_request.name, + }, + ) + return super().get_sign_oca_access(signer_id, access_token, **kwargs) + + @http.route( + ["/sign_oca/sign//"], + type="json", + auth="public", + website=True, + ) + def get_sign_oca_sign_access( + self, signer_id, access_token, items, latitude=False, longitude=False + ): + try: + signer_sudo = self._document_check_access( + "sign.oca.request.signer", signer_id, access_token + ) + except (AccessError, MissingError): + return request.redirect("/my") + + if ( + signer_sudo.request_id.signing_mode == "sequential" + and signer_sudo.signer_state == "waiting" + ): + return {"error": "It is not your turn to sign yet."} + return super().get_sign_oca_sign_access( + signer_id, access_token, items, latitude=latitude, longitude=longitude + ) diff --git a/sign_oca_order/data/mail_template_data.xml b/sign_oca_order/data/mail_template_data.xml new file mode 100644 index 00000000..e02149b9 --- /dev/null +++ b/sign_oca_order/data/mail_template_data.xml @@ -0,0 +1,207 @@ + + + + + + + + + + + diff --git a/sign_oca_order/models/__init__.py b/sign_oca_order/models/__init__.py new file mode 100644 index 00000000..3737f1b5 --- /dev/null +++ b/sign_oca_order/models/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2025 Keboola +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import sign_oca_request +from . import sign_oca_request_signer +from . import sign_oca_request_log diff --git a/sign_oca_order/models/sign_oca_request.py b/sign_oca_order/models/sign_oca_request.py new file mode 100644 index 00000000..6feee7ec --- /dev/null +++ b/sign_oca_order/models/sign_oca_request.py @@ -0,0 +1,311 @@ +# Copyright 2025 Keboola +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class SignOcaRequest(models.Model): + _inherit = "sign.oca.request" + + signing_mode = fields.Selection( + selection=[ + ("parallel", "Parallel (All at once)"), + ("sequential", "Sequential (Ordered)"), + ], + default="parallel", + required=True, + copy=True, + ) + cc_partner_ids = fields.Many2many( + comodel_name="res.partner", + relation="sign_oca_request_cc_partner_rel", + column1="request_id", + column2="partner_id", + string="CC Recipients", + ) + current_signing_order = fields.Integer( + default=0, + copy=False, + readonly=True, + ) + + def action_send(self, sign_now=False, message=""): + """Send the sign request, handling parallel and sequential modes.""" + for record in self: + if record.signing_mode == "parallel": + super(SignOcaRequest, record).action_send( + sign_now=sign_now, + message=message, + ) + for signer in record.signer_ids: + signer.signer_state = "sent" + else: + record._action_send_sequential( + sign_now=sign_now, + message=message, + ) + return True + + def _action_send_sequential(self, sign_now=False, message=""): + """Send in sequential mode: only first-order signers get notified.""" + self.ensure_one() + if self.state != "1_draft": + return + self._set_action_log("validate") + self.state = "0_sent" + + first_order = min(self.signer_ids.mapped("signing_order")) + self.current_signing_order = first_order + + for signer in self.signer_ids: + signer._portal_ensure_token() + if signer.signing_order == first_order: + signer.signer_state = "sent" + else: + signer.signer_state = "waiting" + + first_signers = self.signer_ids.filtered( + lambda s: s.signing_order == first_order + ) + self._send_signing_notification( + first_signers, + sign_now=sign_now, + message=message, + ) + + if not self.sent_date: + self.sent_date = fields.Datetime.now() + + def _send_signing_notification(self, signers, sign_now=False, message=""): + """Send signing invitation emails to the specified signers.""" + self.ensure_one() + for signer in signers: + signer._portal_ensure_token() + if sign_now and signer.partner_id == self.env.user.partner_id: + continue + base_url = signer.get_base_url() + access_url = signer.access_url + if not access_url.startswith("http"): + access_url = base_url + access_url + render_result = self.env["ir.qweb"]._render( + "sign_oca_order.sign_oca_sequential_initial_mail", + { + "record": self, + "signer": signer, + "body": message, + "link": access_url, + }, + engine="ir.qweb", + minimal_qcontext=True, + ) + self.env["mail.thread"].message_notify( + body=render_result, + partner_ids=signer.partner_id.ids, + subject="New document to sign", + subtype_id=self.env.ref("mail.mt_comment").id, + mail_auto_delete=False, + email_layout_xmlid="mail.mail_notification_light", + ) + + def _send_next_step_notification(self, signers): + """Send 'your turn to sign' emails for sequential advancement.""" + self.ensure_one() + for signer in signers: + signer._portal_ensure_token() + base_url = signer.get_base_url() + access_url = signer.access_url + if not access_url.startswith("http"): + access_url = base_url + access_url + render_result = self.env["ir.qweb"]._render( + "sign_oca_order.sign_oca_your_turn_mail", + { + "record": self, + "signer": signer, + "link": access_url, + }, + engine="ir.qweb", + minimal_qcontext=True, + ) + self.env["mail.thread"].message_notify( + body=render_result, + partner_ids=signer.partner_id.ids, + subject=f"Your turn to sign: {self.name}", + subtype_id=self.env.ref("mail.mt_comment").id, + mail_auto_delete=False, + email_layout_xmlid="mail.mail_notification_light", + ) + + def _advance_to_next_step(self): + """Advance to the next signing step in sequential mode.""" + self.ensure_one() + if self.signing_mode != "sequential": + return + current_step_signers = self.signer_ids.filtered( + lambda s: s.signing_order == self.current_signing_order + ) + if not all(s.signed_on for s in current_step_signers): + return + remaining_orders = self.signer_ids.filtered( + lambda s: not s.signed_on and s.signing_order > self.current_signing_order + ).mapped("signing_order") + if remaining_orders: + next_order = min(remaining_orders) + self.current_signing_order = next_order + next_signers = self.signer_ids.filtered( + lambda s: s.signing_order == next_order + ) + for signer in next_signers: + signer.signer_state = "sent" + self._send_next_step_notification(next_signers) + self._set_action_log("advance_step") + + def _check_signed(self): + """Update signer states, advance sequential steps, and send CC.""" + pre_signed = {r.id: r.state == "2_signed" for r in self} + for record in self: + for signer in record.signer_ids: + if signer.signed_on and signer.signer_state != "signed": + signer.signer_state = "signed" + record._advance_to_next_step() + result = super()._check_signed() + for record in self: + if not pre_signed[record.id] and record.state == "2_signed": + record._send_cc_notification() + return result + + def _send_cc_notification(self): + """Send CC notification with signed PDF attached.""" + self.ensure_one() + if not self.cc_partner_ids: + return + attachment = self.env["ir.attachment"].create( + { + "name": self.filename or f"{self.name}.pdf", + "res_model": "sign.oca.request", + "res_id": self.id, + "datas": self.data, + "type": "binary", + } + ) + render_result = self.env["ir.qweb"]._render( + "sign_oca_order.sign_oca_cc_notification_mail", + {"record": self}, + engine="ir.qweb", + minimal_qcontext=True, + ) + self.env["mail.thread"].message_notify( + body=render_result, + partner_ids=self.cc_partner_ids.ids, + subject=f"Document signed: {self.name}", + subtype_id=self.env.ref("mail.mt_comment").id, + mail_auto_delete=False, + attachment_ids=[attachment.id], + email_layout_xmlid="mail.mail_notification_light", + ) + self._set_action_log("cc_notify") + + def action_send_signed_request(self): + """Send a nicely formatted signed document copy to all signers.""" + self.ensure_one() + if ( + self.state != "2_signed" + or not self.env.company.sign_oca_send_sign_request_copy + ): + return + attachment = self.env["ir.attachment"].create( + { + "name": self.filename or f"{self.name}.pdf", + "res_model": "sign.oca.request", + "res_id": self.id, + "datas": self.data, + "type": "binary", + } + ) + render_result = self.env["ir.qweb"]._render( + "sign_oca_order.sign_oca_signed_copy_mail", + {"record": self}, + engine="ir.qweb", + minimal_qcontext=True, + ) + self.env["mail.thread"].message_notify( + body=render_result, + partner_ids=self.signer_ids.mapped("partner_id").ids, + subject=f"Document signed: {self.name}", + subtype_id=self.env.ref("mail.mt_comment").id, + mail_auto_delete=False, + attachment_ids=[attachment.id], + email_layout_xmlid="mail.mail_notification_light", + ) + + def action_resend(self): + """Resend notifications, respecting sequential signing order.""" + self.ensure_one() + if self.state != "0_sent": + return + if self.signing_mode == "parallel": + return super().action_resend() + # Sequential mode: only resend to current step's unsigned signers + current_signers = self.signer_ids.filtered( + lambda s: not s.signed_on and s.signing_order == self.current_signing_order + ) + if not current_signers: + return + self._send_reminder_to_signers(current_signers, is_manual=True) + self._set_action_log("resend") + + @api.model + def _cron_send_reminders(self): + """Expire overdue requests and send reminders with sequential awareness.""" + today = fields.Date.context_today(self) + now = fields.Datetime.now() + + # Phase 1: Expire overdue requests + expired_requests = self.search( + [ + ("state", "=", "0_sent"), + ("validity_date", "!=", False), + ("validity_date", "<", today), + ] + ) + for request in expired_requests: + try: + request._expire_request() + except Exception: + _logger.exception( + "Failed to expire sign request %s (id=%s)", + request.name, + request.id, + ) + + # Phase 2: Send reminders with sequential awareness + due_requests = self.search( + [ + ("state", "=", "0_sent"), + ("reminder_enabled", "=", True), + ("next_reminder_date", "<=", now), + ] + ) + for request in due_requests: + try: + if request.signing_mode == "sequential": + current_order = request.current_signing_order + unsigned_signers = request.signer_ids.filtered( + lambda s, co=current_order: not s.signed_on + and s.signing_order == co + ) + else: + unsigned_signers = request.signer_ids.filtered( + lambda s: not s.signed_on + ) + if unsigned_signers: + request._send_reminder_to_signers(unsigned_signers) + except Exception: + _logger.exception( + "Failed to send reminder for sign request %s (id=%s)", + request.name, + request.id, + ) diff --git a/sign_oca_order/models/sign_oca_request_log.py b/sign_oca_order/models/sign_oca_request_log.py new file mode 100644 index 00000000..220cc335 --- /dev/null +++ b/sign_oca_order/models/sign_oca_request_log.py @@ -0,0 +1,19 @@ +# Copyright 2025 Keboola +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class SignOcaRequestLog(models.Model): + _inherit = "sign.oca.request.log" + + action = fields.Selection( + selection_add=[ + ("cc_notify", "CC Notification"), + ("advance_step", "Advance Step"), + ], + ondelete={ + "cc_notify": "cascade", + "advance_step": "cascade", + }, + ) diff --git a/sign_oca_order/models/sign_oca_request_signer.py b/sign_oca_order/models/sign_oca_request_signer.py new file mode 100644 index 00000000..8e2b3c8d --- /dev/null +++ b/sign_oca_order/models/sign_oca_request_signer.py @@ -0,0 +1,52 @@ +# Copyright 2025 Keboola +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models +from odoo.exceptions import UserError + + +class SignOcaRequestSigner(models.Model): + _inherit = "sign.oca.request.signer" + + signing_order = fields.Integer( + default=10, + help=( + "Order in which this signer signs. Lower values go first. " + "Signers with the same value sign in parallel." + ), + ) + signer_state = fields.Selection( + selection=[ + ("draft", "Draft"), + ("waiting", "Waiting"), + ("sent", "Sent"), + ("signed", "Signed"), + ], + default="draft", + copy=False, + readonly=True, + ) + + def action_sign(self, items, access_token=False, latitude=False, longitude=False): + """Validate sequential signing order before allowing signature. + + When the request uses sequential signing mode, a signer whose state + is still 'waiting' is not yet allowed to sign -- previous signers + must complete first. + """ + if ( + self.request_id.signing_mode == "sequential" + and self.signer_state == "waiting" + ): + raise UserError( + self.env._( + "It is not your turn to sign this document yet. " + "Please wait for the previous signers to complete." + ) + ) + return super().action_sign( + items, + access_token=access_token, + latitude=latitude, + longitude=longitude, + ) diff --git a/sign_oca_order/pyproject.toml b/sign_oca_order/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/sign_oca_order/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/sign_oca_order/readme/CONFIGURE.md b/sign_oca_order/readme/CONFIGURE.md new file mode 100644 index 00000000..f5aab953 --- /dev/null +++ b/sign_oca_order/readme/CONFIGURE.md @@ -0,0 +1,7 @@ +No special configuration needed. The module adds new fields to the sign request form: + +- **Signing Mode**: Choose "Parallel" (all at once, default) or "Sequential" (ordered) +- **CC Recipients**: Partners who receive a copy of the signed document after completion + +When using sequential mode, set the signing order on each signer line. +Signers with the same order number sign simultaneously within that step. diff --git a/sign_oca_order/readme/CONTRIBUTORS.md b/sign_oca_order/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..cfadbdfc --- /dev/null +++ b/sign_oca_order/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Keboola](https://www.keboola.com): + - Jiri Manas diff --git a/sign_oca_order/readme/DESCRIPTION.md b/sign_oca_order/readme/DESCRIPTION.md new file mode 100644 index 00000000..33a7aeec --- /dev/null +++ b/sign_oca_order/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +This module extends OCA Sign with sequential (ordered) signing and CC recipient +functionality, similar to DocuSign's routing and carbon copy features. diff --git a/sign_oca_order/readme/USAGE.md b/sign_oca_order/readme/USAGE.md new file mode 100644 index 00000000..999a6331 --- /dev/null +++ b/sign_oca_order/readme/USAGE.md @@ -0,0 +1,20 @@ +**Sequential Signing:** + +1. Create a new sign request +2. Set Signing Mode to "Sequential" +3. Add signers and set their Signing Order (1, 2, 3...) +4. Send the request -- only order 1 signers are notified +5. After order 1 completes, order 2 signers are automatically notified +6. Repeat until all steps complete + +**CC Recipients:** + +1. Add partners to the CC Recipients field on the sign request +2. After all signers complete, CC recipients receive an email with the signed PDF attached + +**Parallel Groups:** + +Signers with the same signing order number sign simultaneously. For example: + +- Order 1: Alice, Bob (both sign in parallel) +- Order 2: Carol (signs after Alice AND Bob complete) diff --git a/sign_oca_order/static/description/index.html b/sign_oca_order/static/description/index.html new file mode 100644 index 00000000..7793e2ac --- /dev/null +++ b/sign_oca_order/static/description/index.html @@ -0,0 +1,21 @@ + + + + + +

Sign OCA Sequential Signing & CC

+

Extends OCA Sign with sequential/ordered signing and CC recipients.

+

Features

+
    +
  • Sequential signing: Define the order in which signers must sign. Only the current signer receives the signing request.
  • +
  • Parallel groups: Signers with the same order number can sign simultaneously (DocuSign-style).
  • +
  • CC recipients: Partners who receive a notification with the signed PDF after all signatures are complete.
  • +
  • Signer status tracking: Each signer shows their progress: Draft, Waiting, Sent, Signed.
  • +
  • Portal waiting page: Signers who access the link before their turn see a friendly waiting page.
  • +
  • Reminder integration: Automatic reminders only target the current signing step.
  • +
+ + diff --git a/sign_oca_order/tests/__init__.py b/sign_oca_order/tests/__init__.py new file mode 100644 index 00000000..1d62dfa1 --- /dev/null +++ b/sign_oca_order/tests/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2025 Keboola +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_sequential_signing +from . import test_cc_recipients +from . import test_parallel_mode diff --git a/sign_oca_order/tests/test_cc_recipients.py b/sign_oca_order/tests/test_cc_recipients.py new file mode 100644 index 00000000..4aa718d8 --- /dev/null +++ b/sign_oca_order/tests/test_cc_recipients.py @@ -0,0 +1,187 @@ +# Copyright 2025 Keboola +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 + +from odoo import fields +from odoo.tools import misc + +from odoo.addons.base.tests.common import BaseCommon + + +class TestCcRecipients(BaseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.data = base64.b64encode( + open( + misc.file_path("sign_oca/tests/empty.pdf"), + "rb", + ).read() + ) + cls.signer_partner = cls.env["res.partner"].create( + {"name": "Test Signer", "email": "signer@example.com"} + ) + cls.cc_partner_1 = cls.env["res.partner"].create( + {"name": "CC Recipient 1", "email": "cc1@example.com"} + ) + cls.cc_partner_2 = cls.env["res.partner"].create( + {"name": "CC Recipient 2", "email": "cc2@example.com"} + ) + cls.role_customer = cls.env.ref("sign_oca.sign_role_customer") + + def _create_request_with_cc(self, **kwargs): + vals = { + "data": self.data, + "name": "Test Sign Request", + "signer_ids": [ + ( + 0, + 0, + { + "partner_id": self.signer_partner.id, + "role_id": self.role_customer.id, + }, + ), + ], + "cc_partner_ids": [(6, 0, [self.cc_partner_1.id])], + } + vals.update(kwargs) + return self.env["sign.oca.request"].create(vals) + + def test_cc_notification_after_all_signed(self): + """CC recipients notified with signed PDF after all signers complete.""" + request = self._create_request_with_cc() + request.action_send() + # Mark signer as signed + request.signer_ids[0].signed_on = fields.Datetime.now() + request._check_signed() + self.assertEqual(request.state, "2_signed") + # Assert cc_notify log was created + log = self.env["sign.oca.request.log"].search( + [ + ("request_id", "=", request.id), + ("action", "=", "cc_notify"), + ] + ) + self.assertEqual(len(log), 1) + # Assert ir.attachment was created for the signed PDF + attachment = self.env["ir.attachment"].search( + [ + ("res_model", "=", "sign.oca.request"), + ("res_id", "=", request.id), + ], + order="id desc", + limit=1, + ) + self.assertTrue( + attachment, "Signed PDF attachment should be created for CC notification" + ) + + def test_cc_not_sent_before_complete(self): + """CC notification should NOT be sent when not all signers have signed.""" + signer_partner_2 = self.env["res.partner"].create( + {"name": "Signer Two", "email": "signer2@example.com"} + ) + request = self._create_request_with_cc( + signer_ids=[ + ( + 0, + 0, + { + "partner_id": self.signer_partner.id, + "role_id": self.role_customer.id, + }, + ), + ( + 0, + 0, + { + "partner_id": signer_partner_2.id, + "role_id": self.role_customer.id, + }, + ), + ], + ) + request.action_send() + # Mark only first signer as signed + request.signer_ids[0].signed_on = fields.Datetime.now() + request._check_signed() + self.assertEqual(request.state, "0_sent") + # Assert NO cc_notify log exists + log = self.env["sign.oca.request.log"].search( + [ + ("request_id", "=", request.id), + ("action", "=", "cc_notify"), + ] + ) + self.assertEqual(len(log), 0) + + def test_cc_with_no_recipients(self): + """Request without CC recipients should complete without errors.""" + request = self._create_request_with_cc( + cc_partner_ids=[(6, 0, [])], + ) + request.action_send() + # Mark signer as signed + request.signer_ids[0].signed_on = fields.Datetime.now() + request._check_signed() + self.assertEqual(request.state, "2_signed") + # Assert NO cc_notify log (no error should occur) + log = self.env["sign.oca.request.log"].search( + [ + ("request_id", "=", request.id), + ("action", "=", "cc_notify"), + ] + ) + self.assertEqual(len(log), 0) + + def test_cc_with_sequential_mode(self): + """CC notification should be sent after all sequential signers complete.""" + signer_partner_2 = self.env["res.partner"].create( + {"name": "Signer Two", "email": "signer2@example.com"} + ) + request = self._create_request_with_cc( + signing_mode="sequential", + signer_ids=[ + ( + 0, + 0, + { + "partner_id": self.signer_partner.id, + "role_id": self.role_customer.id, + "signing_order": 10, + }, + ), + ( + 0, + 0, + { + "partner_id": signer_partner_2.id, + "role_id": self.role_customer.id, + "signing_order": 20, + }, + ), + ], + ) + request.action_send() + # Sign first signer (order 10) + first_signer = request.signer_ids.filtered(lambda s: s.signing_order == 10) + first_signer.signed_on = fields.Datetime.now() + request._check_signed() + # Should advance to step 2, not yet complete + self.assertEqual(request.state, "0_sent") + # Sign second signer (order 20) + second_signer = request.signer_ids.filtered(lambda s: s.signing_order == 20) + second_signer.signed_on = fields.Datetime.now() + request._check_signed() + # Now fully signed + self.assertEqual(request.state, "2_signed") + # Assert cc_notify log exists + log = self.env["sign.oca.request.log"].search( + [ + ("request_id", "=", request.id), + ("action", "=", "cc_notify"), + ] + ) + self.assertEqual(len(log), 1) diff --git a/sign_oca_order/tests/test_parallel_mode.py b/sign_oca_order/tests/test_parallel_mode.py new file mode 100644 index 00000000..320c338a --- /dev/null +++ b/sign_oca_order/tests/test_parallel_mode.py @@ -0,0 +1,103 @@ +# Copyright 2025 Keboola +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 + +from odoo import fields +from odoo.tools import misc + +from odoo.addons.base.tests.common import BaseCommon + + +class TestParallelMode(BaseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.data = base64.b64encode( + open( + misc.file_path("sign_oca/tests/empty.pdf"), + "rb", + ).read() + ) + cls.signer_partner = cls.env["res.partner"].create( + {"name": "Test Signer", "email": "signer@example.com"} + ) + cls.signer_partner_2 = cls.env["res.partner"].create( + {"name": "Test Signer 2", "email": "signer2@example.com"} + ) + cls.cc_partner = cls.env["res.partner"].create( + {"name": "CC Recipient", "email": "cc@example.com"} + ) + cls.role_customer = cls.env.ref("sign_oca.sign_role_customer") + + def _create_parallel_request(self, **kwargs): + vals = { + "data": self.data, + "name": "Test Parallel Request", + "signing_mode": "parallel", + "signer_ids": [ + ( + 0, + 0, + { + "partner_id": self.signer_partner.id, + "role_id": self.role_customer.id, + }, + ), + ( + 0, + 0, + { + "partner_id": self.signer_partner_2.id, + "role_id": self.role_customer.id, + }, + ), + ], + } + vals.update(kwargs) + return self.env["sign.oca.request"].create(vals) + + def test_parallel_mode_is_default(self): + """Parallel should be the default signing mode.""" + request = self._create_parallel_request() + self.assertEqual(request.signing_mode, "parallel") + + def test_parallel_all_signers_sent(self): + """All signers should be 'sent' after sending in parallel mode.""" + request = self._create_parallel_request() + request.action_send() + for signer in request.signer_ids: + self.assertEqual( + signer.signer_state, + "sent", + "All signers should be in 'sent' state in parallel mode", + ) + + def test_parallel_resend_sends_all_unsigned(self): + """Resend in parallel mode increments reminder_count.""" + request = self._create_parallel_request() + request.action_send() + # Mark first signer as signed + request.signer_ids[0].signed_on = fields.Datetime.now() + request.action_resend() + self.assertEqual(request.reminder_count, 1) + + def test_parallel_with_cc(self): + """CC notification should be sent after all parallel signers complete.""" + request = self._create_parallel_request( + cc_partner_ids=[(6, 0, [self.cc_partner.id])], + ) + request.action_send() + # Sign both signers + for signer in request.signer_ids: + signer.signed_on = fields.Datetime.now() + request._check_signed() + self.assertEqual(request.state, "2_signed") + # Assert cc_notify log exists + log = self.env["sign.oca.request.log"].search( + [ + ("request_id", "=", request.id), + ("action", "=", "cc_notify"), + ] + ) + self.assertEqual(len(log), 1) diff --git a/sign_oca_order/tests/test_sequential_signing.py b/sign_oca_order/tests/test_sequential_signing.py new file mode 100644 index 00000000..7bf9a159 --- /dev/null +++ b/sign_oca_order/tests/test_sequential_signing.py @@ -0,0 +1,312 @@ +# Copyright 2025 Keboola +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +from datetime import timedelta + +from odoo import fields +from odoo.exceptions import UserError +from odoo.tools import misc + +from odoo.addons.base.tests.common import BaseCommon + + +class TestSequentialSigning(BaseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.data = base64.b64encode( + open( + misc.file_path("sign_oca/tests/empty.pdf"), + "rb", + ).read() + ) + cls.partner_1 = cls.env["res.partner"].create( + {"name": "Signer One", "email": "signer1@example.com"} + ) + cls.partner_2 = cls.env["res.partner"].create( + {"name": "Signer Two", "email": "signer2@example.com"} + ) + cls.partner_3 = cls.env["res.partner"].create( + {"name": "Signer Three", "email": "signer3@example.com"} + ) + cls.role_customer = cls.env.ref("sign_oca.sign_role_customer") + + def _create_sequential_request(self, **kwargs): + """Create a sequential sign request with 3 signers at orders 10, 20, 30.""" + vals = { + "data": self.data, + "name": "Test Sequential", + "signing_mode": "sequential", + "signer_ids": [ + ( + 0, + 0, + { + "partner_id": self.partner_1.id, + "role_id": self.role_customer.id, + "signing_order": 10, + }, + ), + ( + 0, + 0, + { + "partner_id": self.partner_2.id, + "role_id": self.role_customer.id, + "signing_order": 20, + }, + ), + ( + 0, + 0, + { + "partner_id": self.partner_3.id, + "role_id": self.role_customer.id, + "signing_order": 30, + }, + ), + ], + } + vals.update(kwargs) + return self.env["sign.oca.request"].create(vals) + + def _get_signer_by_partner(self, request, partner): + """Return the signer record matching the given partner.""" + return request.signer_ids.filtered(lambda s: s.partner_id == partner) + + def _get_signers_by_order(self, request, order): + """Return signer records matching the given signing_order.""" + return request.signer_ids.filtered(lambda s: s.signing_order == order) + + # ---------------------------------------------------------------- + # 1. Send only activates the first step + # ---------------------------------------------------------------- + + def test_sequential_send_first_step_only(self): + """Sending a sequential request should only activate the first step.""" + request = self._create_sequential_request() + request.action_send() + + self.assertEqual(request.state, "0_sent") + self.assertEqual(request.current_signing_order, 10) + + signer_1 = self._get_signer_by_partner(request, self.partner_1) + signer_2 = self._get_signer_by_partner(request, self.partner_2) + signer_3 = self._get_signer_by_partner(request, self.partner_3) + + self.assertEqual(signer_1.signer_state, "sent") + self.assertEqual(signer_2.signer_state, "waiting") + self.assertEqual(signer_3.signer_state, "waiting") + self.assertTrue(request.sent_date) + + # ---------------------------------------------------------------- + # 2. Advance after first signer signs + # ---------------------------------------------------------------- + + def test_sequential_advance_after_signing(self): + """After the first signer signs, the second step should activate.""" + request = self._create_sequential_request() + request.action_send() + + signer_1 = self._get_signer_by_partner(request, self.partner_1) + signer_1.signed_on = fields.Datetime.now() + request._check_signed() + + self.assertEqual(request.current_signing_order, 20) + + signer_2 = self._get_signer_by_partner(request, self.partner_2) + signer_3 = self._get_signer_by_partner(request, self.partner_3) + + self.assertEqual(signer_2.signer_state, "sent") + self.assertEqual(signer_3.signer_state, "waiting") + self.assertEqual(request.state, "0_sent") + + # ---------------------------------------------------------------- + # 3. Same order => parallel within the step + # ---------------------------------------------------------------- + + def test_sequential_same_order_parallel(self): + """Signers with the same order should sign in parallel within a step.""" + request = self._create_sequential_request( + signer_ids=[ + ( + 0, + 0, + { + "partner_id": self.partner_1.id, + "role_id": self.role_customer.id, + "signing_order": 10, + }, + ), + ( + 0, + 0, + { + "partner_id": self.partner_2.id, + "role_id": self.role_customer.id, + "signing_order": 10, + }, + ), + ( + 0, + 0, + { + "partner_id": self.partner_3.id, + "role_id": self.role_customer.id, + "signing_order": 20, + }, + ), + ], + ) + request.action_send() + + signer_1 = self._get_signer_by_partner(request, self.partner_1) + signer_2 = self._get_signer_by_partner(request, self.partner_2) + signer_3 = self._get_signer_by_partner(request, self.partner_3) + + # Both order-10 signers should be sent + self.assertEqual(signer_1.signer_state, "sent") + self.assertEqual(signer_2.signer_state, "sent") + self.assertEqual(signer_3.signer_state, "waiting") + + # Sign only partner_1 + signer_1.signed_on = fields.Datetime.now() + request._check_signed() + + # partner_2 is still pending in the same step -- no advancement + self.assertEqual(signer_2.signer_state, "sent") + self.assertEqual(signer_3.signer_state, "waiting") + self.assertEqual(request.current_signing_order, 10) + + # Now sign partner_2 as well + signer_2.signed_on = fields.Datetime.now() + request._check_signed() + + # Step should now advance to order 20 + self.assertEqual(signer_3.signer_state, "sent") + self.assertEqual(request.current_signing_order, 20) + + # ---------------------------------------------------------------- + # 4. All signers complete => request fully signed + # ---------------------------------------------------------------- + + def test_sequential_all_signed_completes(self): + """Signing all signers in order should mark the request as signed.""" + request = self._create_sequential_request() + request.action_send() + + # Sign signer 1 (order 10) + signer_1 = self._get_signer_by_partner(request, self.partner_1) + signer_1.signed_on = fields.Datetime.now() + request._check_signed() + + # Sign signer 2 (order 20) + signer_2 = self._get_signer_by_partner(request, self.partner_2) + signer_2.signed_on = fields.Datetime.now() + request._check_signed() + + # Sign signer 3 (order 30) + signer_3 = self._get_signer_by_partner(request, self.partner_3) + signer_3.signed_on = fields.Datetime.now() + request._check_signed() + + self.assertEqual(request.state, "2_signed") + + # ---------------------------------------------------------------- + # 5. Resend only targets current step + # ---------------------------------------------------------------- + + def test_sequential_resend_only_current(self): + """Resend should only notify unsigned signers in the current step.""" + request = self._create_sequential_request() + request.action_send() + + # Resend at step 1 + request.action_resend() + self.assertEqual(request.reminder_count, 1) + + # Sign signer 1, advance to step 2 + signer_1 = self._get_signer_by_partner(request, self.partner_1) + signer_1.signed_on = fields.Datetime.now() + request._check_signed() + self.assertEqual(request.current_signing_order, 20) + + # Resend at step 2 + request.action_resend() + self.assertEqual(request.reminder_count, 2) + + # ---------------------------------------------------------------- + # 6. Cron reminder respects sequential order + # ---------------------------------------------------------------- + + def test_sequential_cron_reminder_only_current(self): + """Cron reminders should only target the current step's signers.""" + request = self._create_sequential_request( + reminder_enabled=True, + reminder_interval_days=1, + ) + request.action_send() + + # Backdate sent_date so the reminder becomes due + request.sent_date = fields.Datetime.now() - timedelta(days=2) + request.invalidate_recordset() + + self.env["sign.oca.request"]._cron_send_reminders() + + self.assertEqual(request.reminder_count, 1) + + # ---------------------------------------------------------------- + # 7. Waiting signer cannot sign + # ---------------------------------------------------------------- + + def test_action_sign_blocks_waiting_signer(self): + """A signer in 'waiting' state should be blocked from signing.""" + request = self._create_sequential_request() + request.action_send() + + signer_2 = self._get_signer_by_partner(request, self.partner_2) + self.assertEqual(signer_2.signer_state, "waiting") + + with self.assertRaises(UserError): + signer_2.action_sign( + items={}, + access_token=signer_2.access_token, + ) + + # ---------------------------------------------------------------- + # 8. Advancing step creates a log entry + # ---------------------------------------------------------------- + + def test_advance_step_logs_action(self): + """Advancing to the next step should create an 'advance_step' log.""" + request = self._create_sequential_request() + request.action_send() + + signer_1 = self._get_signer_by_partner(request, self.partner_1) + signer_1.signed_on = fields.Datetime.now() + request._check_signed() + + log = self.env["sign.oca.request.log"].search( + [ + ("request_id", "=", request.id), + ("action", "=", "advance_step"), + ] + ) + self.assertEqual(len(log), 1) + + # ---------------------------------------------------------------- + # 9. Expiration works in sequential mode + # ---------------------------------------------------------------- + + def test_expiration_works_in_sequential_mode(self): + """A sequential request past its validity date should be expired.""" + request = self._create_sequential_request( + validity_date=fields.Date.context_today(self.env["sign.oca.request"]) + - timedelta(days=1), + ) + request.action_send() + + self.env["sign.oca.request"]._cron_send_reminders() + + self.assertEqual(request.state, "3_cancel") diff --git a/sign_oca_order/views/portal_templates.xml b/sign_oca_order/views/portal_templates.xml new file mode 100644 index 00000000..b6a445ec --- /dev/null +++ b/sign_oca_order/views/portal_templates.xml @@ -0,0 +1,83 @@ + + + + + diff --git a/sign_oca_order/views/sign_oca_request_views.xml b/sign_oca_order/views/sign_oca_request_views.xml new file mode 100644 index 00000000..bb7f4f63 --- /dev/null +++ b/sign_oca_order/views/sign_oca_request_views.xml @@ -0,0 +1,62 @@ + + + + + + sign.oca.request.form.inherit.sign_oca_order + sign.oca.request + + 15 + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + diff --git a/sign_oca_reminder/README.rst b/sign_oca_reminder/README.rst new file mode 100644 index 00000000..1b4703cd --- /dev/null +++ b/sign_oca_reminder/README.rst @@ -0,0 +1,112 @@ +=============================== +Sign OCA Reminders & Expiration +=============================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:c63b9e14cda4cfb4a36e70d4ddb13c6d3f8d065ccc3b826875e35f8c24389149 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsign-lightgray.png?logo=github + :target: https://github.com/OCA/sign/tree/18.0/sign_oca_reminder + :alt: OCA/sign +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/sign-18-0/sign-18-0-sign_oca_reminder + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/sign&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds DocuSign-like reminder, resend, and expiration +functionality to OCA's sign_oca module. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Go to Settings > Sign OCA to configure: + +- **Automatic Reminders**: Enable/disable reminders by default on new + requests +- **Reminder Interval**: Days between automatic reminders (default: 3) +- **Validity Days**: Default expiration period for new requests (0 = no + expiration) + +These defaults can be overridden on each individual sign request. + +Usage +===== + +After installing: + +1. **Manual Resend**: Open a sent sign request and click "Resend" to + re-notify unsigned signers +2. **Automatic Reminders**: Enable on a request to have the daily cron + send reminders at the configured interval +3. **Expiration**: Set a validity date; requests past this date are + automatically cancelled by the daily cron +4. **Filters**: Use "Expiring Soon" and "Expired" search filters in the + sign request list + +Known issues / Roadmap +====================== + +- Support per-signer reminder preferences +- Add reminder history log accessible from the request form + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Keboola + +Contributors +------------ + +- `Keboola `__: + + - Jiri Manas + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/sign `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/sign_oca_reminder/__init__.py b/sign_oca_reminder/__init__.py new file mode 100644 index 00000000..3a45dc77 --- /dev/null +++ b/sign_oca_reminder/__init__.py @@ -0,0 +1,48 @@ +# Copyright 2025 Keboola +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +# -*- coding: utf-8 -*- +import logging + +from . import models + +_logger = logging.getLogger(__name__) + + +def post_init_hook(env): + """Backfill sent_date for existing sent requests on fresh install.""" + _backfill_sent_date(env) + + +def _backfill_sent_date(env): + """Set sent_date = create_date for requests already in '0_sent' state. + + When the module is installed on a database with existing sign requests + that were sent before the module was available, sent_date is NULL. + Without sent_date, next_reminder_date cannot be computed and reminders + never fire. + """ + env.cr.execute(""" + UPDATE sign_oca_request + SET sent_date = create_date + WHERE state = '0_sent' + AND sent_date IS NULL + """) + updated = env.cr.rowcount + if updated: + _logger.info( + "Backfilled sent_date for %d existing sign request(s).", + updated, + ) + # Trigger recomputation of stored next_reminder_date + requests = env["sign.oca.request"].search( + [ + ("state", "=", "0_sent"), + ("reminder_enabled", "=", True), + ] + ) + if requests: + requests._compute_next_reminder_date() + _logger.info( + "Recomputed next_reminder_date for %d request(s).", + len(requests), + ) diff --git a/sign_oca_reminder/__manifest__.py b/sign_oca_reminder/__manifest__.py new file mode 100644 index 00000000..d9e4da2f --- /dev/null +++ b/sign_oca_reminder/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2025 Keboola +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Sign OCA Reminders & Expiration", + "version": "18.0.1.0.0", + "category": "Sign", + "summary": "Automatic reminders, manual resend, and expiration for sign requests", + "author": "Keboola, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/sign", + "license": "AGPL-3", + "depends": ["sign_oca"], + "development_status": "Beta", + "data": [ + "data/cron_data.xml", + "data/mail_template_data.xml", + "views/sign_oca_request_views.xml", + "views/res_config_settings_views.xml", + ], + "post_init_hook": "post_init_hook", + "installable": True, + "application": False, + "auto_install": False, +} diff --git a/sign_oca_reminder/data/cron_data.xml b/sign_oca_reminder/data/cron_data.xml new file mode 100644 index 00000000..6f514c5f --- /dev/null +++ b/sign_oca_reminder/data/cron_data.xml @@ -0,0 +1,15 @@ + + + + + Sign OCA: Send Reminders and Expire Requests + + code + model._cron_send_reminders() + 1 + days + 10 + + + diff --git a/sign_oca_reminder/data/mail_template_data.xml b/sign_oca_reminder/data/mail_template_data.xml new file mode 100644 index 00000000..cb1a09b8 --- /dev/null +++ b/sign_oca_reminder/data/mail_template_data.xml @@ -0,0 +1,53 @@ + + + + + diff --git a/sign_oca_reminder/models/__init__.py b/sign_oca_reminder/models/__init__.py new file mode 100644 index 00000000..a0d828a8 --- /dev/null +++ b/sign_oca_reminder/models/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2025 Keboola +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +# -*- coding: utf-8 -*- +from . import res_company +from . import res_config_settings +from . import sign_oca_request diff --git a/sign_oca_reminder/models/res_company.py b/sign_oca_reminder/models/res_company.py new file mode 100644 index 00000000..91bcb226 --- /dev/null +++ b/sign_oca_reminder/models/res_company.py @@ -0,0 +1,25 @@ +# Copyright 2025 Keboola +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +# -*- coding: utf-8 -*- +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + sign_oca_reminder_enabled = fields.Boolean( + string="Enable Automatic Reminders", + default=False, + help="Enable automatic email reminders for pending sign requests.", + ) + sign_oca_reminder_interval_days = fields.Integer( + string="Reminder Interval (Days)", + default=3, + help="Number of days between automatic reminders for pending sign requests.", + ) + sign_oca_validity_days = fields.Integer( + string="Default Validity (Days)", + default=0, + help="Default number of days before a sign request expires. " + "0 means no expiration.", + ) diff --git a/sign_oca_reminder/models/res_config_settings.py b/sign_oca_reminder/models/res_config_settings.py new file mode 100644 index 00000000..9c47cb3b --- /dev/null +++ b/sign_oca_reminder/models/res_config_settings.py @@ -0,0 +1,21 @@ +# Copyright 2025 Keboola +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +# -*- coding: utf-8 -*- +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + sign_oca_reminder_enabled = fields.Boolean( + related="company_id.sign_oca_reminder_enabled", + readonly=False, + ) + sign_oca_reminder_interval_days = fields.Integer( + related="company_id.sign_oca_reminder_interval_days", + readonly=False, + ) + sign_oca_validity_days = fields.Integer( + related="company_id.sign_oca_validity_days", + readonly=False, + ) diff --git a/sign_oca_reminder/models/sign_oca_request.py b/sign_oca_reminder/models/sign_oca_request.py new file mode 100644 index 00000000..3301145c --- /dev/null +++ b/sign_oca_reminder/models/sign_oca_request.py @@ -0,0 +1,238 @@ +# Copyright 2025 Keboola +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import logging +from datetime import timedelta + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class SignOcaRequest(models.Model): + _inherit = "sign.oca.request" + + sent_date = fields.Datetime( + copy=False, + readonly=True, + help="Date and time when the request was sent to signers.", + ) + reminder_enabled = fields.Boolean( + string="Automatic Reminders", + help="Send periodic email reminders to unsigned signers.", + ) + reminder_interval_days = fields.Integer( + string="Reminder Interval (Days)", + help="Number of days between automatic reminders.", + ) + last_reminder_date = fields.Datetime( + string="Last Reminder Sent", + copy=False, + readonly=True, + ) + reminder_count = fields.Integer( + string="Reminders Sent", + default=0, + copy=False, + readonly=True, + ) + validity_date = fields.Date( + string="Expiration Date", + copy=False, + help="Date after which the request will be automatically cancelled.", + ) + is_expired = fields.Boolean( + compute="_compute_is_expired", + string="Expired", + ) + next_reminder_date = fields.Datetime( + compute="_compute_next_reminder_date", + store=True, + string="Next Reminder", + ) + + @api.depends("validity_date") + def _compute_is_expired(self): + today = fields.Date.context_today(self) + for record in self: + record.is_expired = record.validity_date and record.validity_date < today + + @api.depends( + "reminder_enabled", + "reminder_interval_days", + "sent_date", + "last_reminder_date", + "state", + ) + def _compute_next_reminder_date(self): + for record in self: + if ( + not record.reminder_enabled + or record.state != "0_sent" + or not record.reminder_interval_days + ): + record.next_reminder_date = False + continue + base_date = record.last_reminder_date or record.sent_date + if not base_date: + record.next_reminder_date = False + continue + record.next_reminder_date = base_date + timedelta( + days=record.reminder_interval_days + ) + + @api.model + def default_get(self, fields_list): + defaults = super().default_get(fields_list) + company = self.env.company + if "reminder_enabled" in fields_list: + defaults["reminder_enabled"] = company.sign_oca_reminder_enabled + if "reminder_interval_days" in fields_list: + defaults["reminder_interval_days"] = ( + company.sign_oca_reminder_interval_days or 3 + ) + if "validity_date" in fields_list and company.sign_oca_validity_days: + defaults["validity_date"] = fields.Date.context_today(self) + timedelta( + days=company.sign_oca_validity_days + ) + return defaults + + def action_send(self, sign_now=False, message=""): + result = super().action_send(sign_now=sign_now, message=message) + for record in self: + if record.state == "0_sent" and not record.sent_date: + record.sent_date = fields.Datetime.now() + return result + + def action_resend(self): + self.ensure_one() + if self.state != "0_sent": + return + unsigned_signers = self.signer_ids.filtered(lambda s: not s.signed_on) + if not unsigned_signers: + return + self._send_reminder_to_signers(unsigned_signers, is_manual=True) + self._set_action_log("resend") + + def _send_reminder_to_signers(self, signers, is_manual=False): + self.ensure_one() + base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url", "") + for signer in signers: + signer._portal_ensure_token() + access_url = signer.access_url + if not access_url.startswith("http"): + access_url = base_url + access_url + render_result = self.env["ir.qweb"]._render( + "sign_oca_reminder.sign_oca_reminder_mail", + { + "record": self, + "signer": signer, + "link": access_url, + "is_manual": is_manual, + }, + engine="ir.qweb", + minimal_qcontext=True, + ) + subject = f"Reminder: {self.name} awaiting your signature" + self.env["mail.thread"].message_notify( + body=render_result, + partner_ids=signer.partner_id.ids, + subject=subject, + subtype_id=self.env.ref("mail.mt_comment").id, + mail_auto_delete=False, + email_layout_xmlid="mail.mail_notification_light", + ) + now = fields.Datetime.now() + self.write( + { + "last_reminder_date": now, + "reminder_count": self.reminder_count + 1, + } + ) + if not is_manual: + self._set_action_log("reminder") + + @api.model + def _cron_send_reminders(self): + today = fields.Date.context_today(self) + now = fields.Datetime.now() + + # Phase 1: Expire overdue requests + expired_requests = self.search( + [ + ("state", "=", "0_sent"), + ("validity_date", "!=", False), + ("validity_date", "<", today), + ] + ) + for request in expired_requests: + try: + request._expire_request() + except Exception: + _logger.exception( + "Failed to expire sign request %s (id=%s)", + request.name, + request.id, + ) + + # Phase 2: Send reminders + due_requests = self.search( + [ + ("state", "=", "0_sent"), + ("reminder_enabled", "=", True), + ("next_reminder_date", "<=", now), + ] + ) + for request in due_requests: + try: + unsigned_signers = request.signer_ids.filtered( + lambda s: not s.signed_on + ) + if unsigned_signers: + request._send_reminder_to_signers(unsigned_signers) + except Exception: + _logger.exception( + "Failed to send reminder for sign request %s (id=%s)", + request.name, + request.id, + ) + + def _expire_request(self): + self.ensure_one() + self.write({"state": "3_cancel"}) + self._set_action_log("expire") + # Notify the request owner + body = ( + f"The sign request {self.name} has expired because it was not " + f"completed before the expiration date ({self.validity_date})." + ) + self.env["mail.thread"].message_notify( + body=body, + partner_ids=self.create_uid.partner_id.ids, + subject=f"Sign request expired: {self.name}", + subtype_id=self.env.ref("mail.mt_comment").id, + mail_auto_delete=False, + ) + + def _check_signed(self): + result = super()._check_signed() + for record in self: + if record.state == "2_signed" and record.reminder_enabled: + record.reminder_enabled = False + return result + + +class SignRequestLog(models.Model): + _inherit = "sign.oca.request.log" + + action = fields.Selection( + selection_add=[ + ("resend", "Resend"), + ("expire", "Expire"), + ("reminder", "Reminder"), + ], + ondelete={ + "resend": "cascade", + "expire": "cascade", + "reminder": "cascade", + }, + ) diff --git a/sign_oca_reminder/pyproject.toml b/sign_oca_reminder/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/sign_oca_reminder/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/sign_oca_reminder/readme/CONFIGURE.md b/sign_oca_reminder/readme/CONFIGURE.md new file mode 100644 index 00000000..e4ebc971 --- /dev/null +++ b/sign_oca_reminder/readme/CONFIGURE.md @@ -0,0 +1,7 @@ +Go to Settings > Sign OCA to configure: + +- **Automatic Reminders**: Enable/disable reminders by default on new requests +- **Reminder Interval**: Days between automatic reminders (default: 3) +- **Validity Days**: Default expiration period for new requests (0 = no expiration) + +These defaults can be overridden on each individual sign request. diff --git a/sign_oca_reminder/readme/CONTRIBUTORS.md b/sign_oca_reminder/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..cfadbdfc --- /dev/null +++ b/sign_oca_reminder/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Keboola](https://www.keboola.com): + - Jiri Manas diff --git a/sign_oca_reminder/readme/DESCRIPTION.md b/sign_oca_reminder/readme/DESCRIPTION.md new file mode 100644 index 00000000..12141ff6 --- /dev/null +++ b/sign_oca_reminder/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +This module adds DocuSign-like reminder, resend, and expiration functionality +to OCA's sign_oca module. diff --git a/sign_oca_reminder/readme/ROADMAP.md b/sign_oca_reminder/readme/ROADMAP.md new file mode 100644 index 00000000..13d057c8 --- /dev/null +++ b/sign_oca_reminder/readme/ROADMAP.md @@ -0,0 +1,2 @@ +- Support per-signer reminder preferences +- Add reminder history log accessible from the request form diff --git a/sign_oca_reminder/readme/USAGE.md b/sign_oca_reminder/readme/USAGE.md new file mode 100644 index 00000000..a241f827 --- /dev/null +++ b/sign_oca_reminder/readme/USAGE.md @@ -0,0 +1,10 @@ +After installing: + +1. **Manual Resend**: Open a sent sign request and click "Resend" to + re-notify unsigned signers +2. **Automatic Reminders**: Enable on a request to have the daily cron + send reminders at the configured interval +3. **Expiration**: Set a validity date; requests past this date are + automatically cancelled by the daily cron +4. **Filters**: Use "Expiring Soon" and "Expired" search filters in + the sign request list diff --git a/sign_oca_reminder/static/description/index.html b/sign_oca_reminder/static/description/index.html new file mode 100644 index 00000000..3f4137d1 --- /dev/null +++ b/sign_oca_reminder/static/description/index.html @@ -0,0 +1,16 @@ +
+
+

Sign OCA Reminders & Expiration

+

Automatic reminders, manual resend, and expiration for sign requests

+
+

Features

+
    +
  • Manual "Resend" button to re-notify unsigned signers
  • +
  • Automatic reminders via daily cron with configurable interval
  • +
  • Request expiration with optional validity date
  • +
  • Company-wide defaults overridable per request
  • +
  • Configurable via Settings > Sign OCA
  • +
+
+
+
diff --git a/sign_oca_reminder/tests/__init__.py b/sign_oca_reminder/tests/__init__.py new file mode 100644 index 00000000..2aae71a5 --- /dev/null +++ b/sign_oca_reminder/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Keboola +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +# -*- coding: utf-8 -*- +from . import test_reminder diff --git a/sign_oca_reminder/tests/test_reminder.py b/sign_oca_reminder/tests/test_reminder.py new file mode 100644 index 00000000..67a7beb5 --- /dev/null +++ b/sign_oca_reminder/tests/test_reminder.py @@ -0,0 +1,299 @@ +# Copyright 2025 Keboola +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +# -*- coding: utf-8 -*- +import base64 +from datetime import timedelta + +from odoo import fields +from odoo.tools import misc + +from odoo.addons.base.tests.common import BaseCommon + + +class TestSignOcaReminder(BaseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.data = base64.b64encode( + open( + misc.file_path("sign_oca/tests/empty.pdf"), + "rb", + ).read() + ) + cls.signer_partner = cls.env["res.partner"].create( + {"name": "Test Signer", "email": "signer@example.com"} + ) + cls.signer_partner_2 = cls.env["res.partner"].create( + {"name": "Test Signer 2", "email": "signer2@example.com"} + ) + cls.role_customer = cls.env.ref("sign_oca.sign_role_customer") + + def _create_request(self, **kwargs): + vals = { + "data": self.data, + "name": "Test Sign Request", + "signer_ids": [ + ( + 0, + 0, + { + "partner_id": self.signer_partner.id, + "role_id": self.role_customer.id, + }, + ), + ], + } + vals.update(kwargs) + return self.env["sign.oca.request"].create(vals) + + # --- Default values from company --- + + def test_default_get_from_company(self): + """Default values should come from company settings.""" + company = self.env.company + company.write( + { + "sign_oca_reminder_enabled": True, + "sign_oca_reminder_interval_days": 5, + "sign_oca_validity_days": 30, + } + ) + request = self._create_request() + self.assertTrue(request.reminder_enabled) + self.assertEqual(request.reminder_interval_days, 5) + expected_date = fields.Date.context_today(request) + timedelta(days=30) + self.assertEqual(request.validity_date, expected_date) + + def test_default_get_no_validity(self): + """When validity_days is 0, no expiration date should be set.""" + company = self.env.company + company.write( + { + "sign_oca_reminder_enabled": False, + "sign_oca_reminder_interval_days": 3, + "sign_oca_validity_days": 0, + } + ) + request = self._create_request() + self.assertFalse(request.reminder_enabled) + self.assertEqual(request.reminder_interval_days, 3) + self.assertFalse(request.validity_date) + + # --- Sent date --- + + def test_sent_date_recorded_on_send(self): + """sent_date should be recorded when request is sent.""" + request = self._create_request() + self.assertFalse(request.sent_date) + request.action_send() + self.assertEqual(request.state, "0_sent") + self.assertTrue(request.sent_date) + + def test_sent_date_not_overwritten(self): + """sent_date should not change if action_send is called again.""" + request = self._create_request() + request.action_send() + original_sent_date = request.sent_date + # Calling again should not change sent_date (state is no longer draft) + request.action_send() + self.assertEqual(request.sent_date, original_sent_date) + + # --- Manual resend --- + + def test_resend_increments_count(self): + """Manual resend should increment reminder_count.""" + request = self._create_request() + request.action_send() + self.assertEqual(request.reminder_count, 0) + request.action_resend() + self.assertEqual(request.reminder_count, 1) + self.assertTrue(request.last_reminder_date) + + def test_resend_logs_action(self): + """Manual resend should create a 'resend' log entry.""" + request = self._create_request() + request.action_send() + request.action_resend() + log = self.env["sign.oca.request.log"].search( + [ + ("request_id", "=", request.id), + ("action", "=", "resend"), + ] + ) + self.assertEqual(len(log), 1) + + def test_resend_does_nothing_on_draft(self): + """Resend should do nothing if request is still in draft.""" + request = self._create_request() + request.action_resend() + self.assertEqual(request.reminder_count, 0) + self.assertFalse(request.last_reminder_date) + + def test_resend_skips_signed_signers(self): + """Resend should only notify unsigned signers.""" + request = self._create_request( + signer_ids=[ + ( + 0, + 0, + { + "partner_id": self.signer_partner.id, + "role_id": self.role_customer.id, + }, + ), + ( + 0, + 0, + { + "partner_id": self.signer_partner_2.id, + "role_id": self.role_customer.id, + }, + ), + ], + ) + request.action_send() + # Mark first signer as signed + request.signer_ids[0].signed_on = fields.Datetime.now() + request.action_resend() + # Should still work (one unsigned signer remains) + self.assertEqual(request.reminder_count, 1) + + # --- Expiration --- + + def test_is_expired_computed(self): + """is_expired should be True when validity_date is in the past.""" + request = self._create_request() + request.validity_date = fields.Date.context_today(request) - timedelta(days=1) + self.assertTrue(request.is_expired) + + def test_is_expired_false_for_future(self): + """is_expired should be False when validity_date is in the future.""" + request = self._create_request() + request.validity_date = fields.Date.context_today(request) + timedelta(days=10) + self.assertFalse(request.is_expired) + + def test_is_expired_false_when_no_validity(self): + """is_expired should be False when no validity_date is set.""" + request = self._create_request() + request.validity_date = False + self.assertFalse(request.is_expired) + + def test_cron_expires_overdue_requests(self): + """Cron should cancel requests past their validity date.""" + request = self._create_request() + request.action_send() + request.validity_date = fields.Date.context_today(request) - timedelta(days=1) + self.env["sign.oca.request"]._cron_send_reminders() + self.assertEqual(request.state, "3_cancel") + log = self.env["sign.oca.request.log"].search( + [ + ("request_id", "=", request.id), + ("action", "=", "expire"), + ] + ) + self.assertEqual(len(log), 1) + + def test_cron_does_not_expire_signed(self): + """Cron should not expire already signed requests.""" + request = self._create_request() + request.action_send() + # Simulate signing + request.state = "2_signed" + request.validity_date = fields.Date.context_today(request) - timedelta(days=1) + self.env["sign.oca.request"]._cron_send_reminders() + # Should remain signed + self.assertEqual(request.state, "2_signed") + + # --- Cron reminders --- + + def test_cron_sends_reminders_when_due(self): + """Cron should send reminders for due requests.""" + request = self._create_request( + reminder_enabled=True, + reminder_interval_days=1, + ) + request.action_send() + # Backdate sent_date to make reminder due + request.sent_date = fields.Datetime.now() - timedelta(days=2) + # Force recompute of next_reminder_date + request.invalidate_recordset() + self.env["sign.oca.request"]._cron_send_reminders() + self.assertEqual(request.reminder_count, 1) + self.assertTrue(request.last_reminder_date) + + def test_cron_skips_disabled_reminders(self): + """Cron should not send reminders when reminder_enabled is False.""" + request = self._create_request( + reminder_enabled=False, + reminder_interval_days=1, + ) + request.action_send() + request.sent_date = fields.Datetime.now() - timedelta(days=2) + request.invalidate_recordset() + self.env["sign.oca.request"]._cron_send_reminders() + self.assertEqual(request.reminder_count, 0) + + # --- next_reminder_date computation --- + + def test_next_reminder_date_computed(self): + """next_reminder_date should be sent_date + interval when enabled.""" + request = self._create_request( + reminder_enabled=True, + reminder_interval_days=3, + ) + request.action_send() + expected = request.sent_date + timedelta(days=3) + self.assertEqual(request.next_reminder_date, expected) + + def test_next_reminder_date_false_when_disabled(self): + """next_reminder_date should be False when reminders are disabled.""" + request = self._create_request( + reminder_enabled=False, + reminder_interval_days=3, + ) + request.action_send() + self.assertFalse(request.next_reminder_date) + + def test_next_reminder_date_after_reminder_sent(self): + """next_reminder_date uses last_reminder_date after first reminder.""" + request = self._create_request( + reminder_enabled=True, + reminder_interval_days=2, + ) + request.action_send() + # Simulate reminder was sent + now = fields.Datetime.now() + request.write( + { + "last_reminder_date": now, + "reminder_count": 1, + } + ) + request.invalidate_recordset() + expected = now + timedelta(days=2) + self.assertEqual(request.next_reminder_date, expected) + + # --- _check_signed disables reminders --- + + def test_check_signed_disables_reminders(self): + """When all signers sign, reminders should be disabled.""" + request = self._create_request(reminder_enabled=True) + request.action_send() + self.assertTrue(request.reminder_enabled) + # Simulate all signers signing + for signer in request.signer_ids: + signer.signed_on = fields.Datetime.now() + request._check_signed() + self.assertEqual(request.state, "2_signed") + self.assertFalse(request.reminder_enabled) + + # --- Log action selection --- + + def test_log_action_selection_extended(self): + """Log action field should include resend, expire, and reminder.""" + log_model = self.env["sign.oca.request.log"] + action_field = log_model._fields["action"] + selection_keys = [s[0] for s in action_field.selection] + self.assertIn("resend", selection_keys) + self.assertIn("expire", selection_keys) + self.assertIn("reminder", selection_keys) diff --git a/sign_oca_reminder/views/res_config_settings_views.xml b/sign_oca_reminder/views/res_config_settings_views.xml new file mode 100644 index 00000000..8b7d8eea --- /dev/null +++ b/sign_oca_reminder/views/res_config_settings_views.xml @@ -0,0 +1,57 @@ + + + + + res.config.settings.view.form.sign_oca_reminder + res.config.settings + + + + +
+ +
+
+
+
+ +
+
+
+
+
+
+
diff --git a/sign_oca_reminder/views/sign_oca_request_views.xml b/sign_oca_reminder/views/sign_oca_request_views.xml new file mode 100644 index 00000000..7e3be051 --- /dev/null +++ b/sign_oca_reminder/views/sign_oca_request_views.xml @@ -0,0 +1,102 @@ + + + + + + sign.oca.request.form.reminder + sign.oca.request + + 20 + + + +