From bb19b047e97a198fd449a3902fa58a652c8b0a38 Mon Sep 17 00:00:00 2001 From: Jiri Manas Date: Sat, 14 Mar 2026 21:44:03 +0100 Subject: [PATCH] [ADD] sign_oca_reminder: reminders, resend, and expiration Add DocuSign-like reminder, resend, and expiration functionality to OCA's sign_oca module. Features: - Manual "Resend" button to re-notify unsigned signers - Automatic reminders via daily cron at configurable intervals - Request expiration with automatic cancellation - Company-level defaults in Settings > Sign OCA - "Expiring Soon" and "Expired" search filters - Post-init hook to backfill sent_date for existing requests Tests: 23 test cases covering reminders, expiration, cron behavior, computed fields, and edge cases. --- sign_oca_reminder/README.rst | 112 +++++++ sign_oca_reminder/__init__.py | 48 +++ sign_oca_reminder/__manifest__.py | 23 ++ sign_oca_reminder/data/cron_data.xml | 15 + sign_oca_reminder/data/mail_template_data.xml | 53 ++++ sign_oca_reminder/models/__init__.py | 6 + sign_oca_reminder/models/res_company.py | 25 ++ .../models/res_config_settings.py | 21 ++ sign_oca_reminder/models/sign_oca_request.py | 238 ++++++++++++++ sign_oca_reminder/pyproject.toml | 3 + sign_oca_reminder/readme/CONFIGURE.md | 7 + sign_oca_reminder/readme/CONTRIBUTORS.md | 2 + sign_oca_reminder/readme/DESCRIPTION.md | 2 + sign_oca_reminder/readme/ROADMAP.md | 2 + sign_oca_reminder/readme/USAGE.md | 10 + .../static/description/index.html | 16 + sign_oca_reminder/tests/__init__.py | 4 + sign_oca_reminder/tests/test_reminder.py | 299 ++++++++++++++++++ .../views/res_config_settings_views.xml | 57 ++++ .../views/sign_oca_request_views.xml | 102 ++++++ 20 files changed, 1045 insertions(+) create mode 100644 sign_oca_reminder/README.rst create mode 100644 sign_oca_reminder/__init__.py create mode 100644 sign_oca_reminder/__manifest__.py create mode 100644 sign_oca_reminder/data/cron_data.xml create mode 100644 sign_oca_reminder/data/mail_template_data.xml create mode 100644 sign_oca_reminder/models/__init__.py create mode 100644 sign_oca_reminder/models/res_company.py create mode 100644 sign_oca_reminder/models/res_config_settings.py create mode 100644 sign_oca_reminder/models/sign_oca_request.py create mode 100644 sign_oca_reminder/pyproject.toml create mode 100644 sign_oca_reminder/readme/CONFIGURE.md create mode 100644 sign_oca_reminder/readme/CONTRIBUTORS.md create mode 100644 sign_oca_reminder/readme/DESCRIPTION.md create mode 100644 sign_oca_reminder/readme/ROADMAP.md create mode 100644 sign_oca_reminder/readme/USAGE.md create mode 100644 sign_oca_reminder/static/description/index.html create mode 100644 sign_oca_reminder/tests/__init__.py create mode 100644 sign_oca_reminder/tests/test_reminder.py create mode 100644 sign_oca_reminder/views/res_config_settings_views.xml create mode 100644 sign_oca_reminder/views/sign_oca_request_views.xml 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 + + + +