diff --git a/attachment_queue/__manifest__.py b/attachment_queue/__manifest__.py index 7522a464d..45cd06bac 100644 --- a/attachment_queue/__manifest__.py +++ b/attachment_queue/__manifest__.py @@ -3,21 +3,19 @@ { "name": "Attachment Queue", - "version": "14.0.1.0.1", + "version": "16.0.1.0.1", "author": "Akretion,Odoo Community Association (OCA)", "summary": "Base module adding the concept of queue for processing files", "website": "https://github.com/OCA/server-tools", "maintainers": ["florian-dacosta", "sebastienbeau"], "license": "AGPL-3", "category": "Generic Modules", - "depends": ["base", "mail"], + "depends": ["base", "mail", "queue_job"], "data": [ "views/attachment_queue_view.xml", "security/ir.model.access.csv", - "data/cron.xml", - "data/ir_config_parameter.xml", "data/mail_template.xml", + "data/queue_job_channel.xml", ], - "demo": ["demo/attachment_queue_demo.xml"], "installable": True, } diff --git a/attachment_queue/data/cron.xml b/attachment_queue/data/cron.xml deleted file mode 100644 index fa8130f4b..000000000 --- a/attachment_queue/data/cron.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - Run Attachments Queue - 30 - minutes - -1 - False - - - code - model.run_attachment_queue_scheduler() - - - diff --git a/attachment_queue/data/ir_config_parameter.xml b/attachment_queue/data/ir_config_parameter.xml deleted file mode 100644 index ecf34bf8c..000000000 --- a/attachment_queue/data/ir_config_parameter.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - attachment_queue_cron_batch_limit - 200 - - diff --git a/attachment_queue/data/queue_job_channel.xml b/attachment_queue/data/queue_job_channel.xml new file mode 100644 index 000000000..5e635cb03 --- /dev/null +++ b/attachment_queue/data/queue_job_channel.xml @@ -0,0 +1,7 @@ + + + + Attachment queues + + + diff --git a/attachment_queue/demo/attachment_queue_demo.xml b/attachment_queue/demo/attachment_queue_demo.xml deleted file mode 100644 index c1d8460e8..000000000 --- a/attachment_queue/demo/attachment_queue_demo.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - bWlncmF0aW9uIHRlc3Q= - attachment_queue_demo.doc - - - diff --git a/attachment_queue/models/attachment_queue.py b/attachment_queue/models/attachment_queue.py index b7d279c8c..64f39f1f3 100644 --- a/attachment_queue/models/attachment_queue.py +++ b/attachment_queue/models/attachment_queue.py @@ -2,10 +2,19 @@ import logging -from odoo import SUPERUSER_ID, api, fields, models, registry +from odoo import api, fields, models +from odoo.exceptions import UserError + +from odoo.addons.queue_job.exception import RetryableJobError _logger = logging.getLogger(__name__) +DEFAULT_ETA_FOR_RETRY = 60 * 60 +STR_ERR_ATTACHMENT_RUNNING = ( + "The attachment is currently flagged as being in processing" +) +STR_ERROR_DURING_PROCESSING = "Error during processing of attachment_queue id {}: \n" + class AttachmentQueue(models.Model): _name = "attachment.queue" @@ -34,10 +43,38 @@ class AttachmentQueue(models.Model): state_message = fields.Text() failure_emails = fields.Char( compute="_compute_failure_emails", - string="Failure Emails", help="Comma-separated list of email addresses to be notified in case of" "failure", ) + running_lock = fields.Boolean() + + @property + def _eta_for_retry(self): + return DEFAULT_ETA_FOR_RETRY + + @property + def _job_attrs(self): + return {"channel": "Attachment queues"} + + def _schedule_jobs(self): + for el in self: + el.with_delay(**self._job_attrs).run() + + @api.model_create_multi + def create(self, vals_list): + res = super().create(vals_list) + res._schedule_jobs() + return res + + def button_reschedule(self): + self.state = "pending" + self.state_message = "" + self._schedule_jobs() + + def button_manual_run(self): + if self.running_lock: + raise UserError(STR_ERR_ATTACHMENT_RUNNING) + self.run() def _compute_failure_emails(self): for attach in self: @@ -48,51 +85,41 @@ class AttachmentQueue(models.Model): self.ensure_one() return "" - @api.model - def run_attachment_queue_scheduler(self, domain=None): - if domain is None: - domain = [("state", "=", "pending")] - batch_limit = self.env.ref( - "attachment_queue.attachment_queue_cron_batch_limit" - ).value - if batch_limit and batch_limit.isdigit(): - limit = int(batch_limit) - else: - limit = 200 - attachments = self.search(domain, limit=limit) - if attachments: - return attachments.run() - return True - def run(self): """ - Run the process for each attachment queue + Run the process for an individual attachment queue """ - failure_tmpl = self.env.ref("attachment_queue.attachment_failure_notification") - for attachment in self: - with api.Environment.manage(): - with registry(self.env.cr.dbname).cursor() as new_cr: - new_env = api.Environment(new_cr, SUPERUSER_ID, self.env.context) - attach = attachment.with_env(new_env) - try: - attach._run() - # pylint: disable=broad-except - except Exception as e: - attach.env.cr.rollback() - _logger.exception(str(e)) - attach.write({"state": "failed", "state_message": str(e)}) - emails = attach.failure_emails - if emails: - failure_tmpl.send_mail(attach.id) - attach.env.cr.commit() - else: - vals = { - "state": "done", - "date_done": fields.Datetime.now(), - } - attach.write(vals) - attach.env.cr.commit() - return True + if self.state != "pending": + return + if self.running_lock is True: + raise RetryableJobError( + STR_ERR_ATTACHMENT_RUNNING, seconds=self._eta_for_retry + ) + self.running_lock = True + self.flush_recordset() + try: + with self.env.cr.savepoint(): + self._run() + except Exception as e: + _logger.warning(STR_ERROR_DURING_PROCESSING.format(self.id) + str(e)) + self.write( + {"state": "failed", "state_message": str(e), "running_lock": False} + ) + emails = self.failure_emails + if emails: + self.env.ref( + "attachment_queue.attachment_failure_notification" + ).send_mail(self.id) + return False + else: + self.write( + { + "state": "done", + "date_done": fields.Datetime.now(), + "running_lock": False, + } + ) + return True def _run(self): self.ensure_one() diff --git a/attachment_queue/tests/test_attachment_queue.py b/attachment_queue/tests/test_attachment_queue.py index 3d8910be3..346841b42 100644 --- a/attachment_queue/tests/test_attachment_queue.py +++ b/attachment_queue/tests/test_attachment_queue.py @@ -1,35 +1,111 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -import odoo -from odoo import api +from unittest import mock + +from odoo_test_helper import FakeModelLoader + +from odoo.exceptions import UserError from odoo.tests.common import TransactionCase +from odoo.addons.queue_job.exception import RetryableJobError +from odoo.addons.queue_job.tests.common import trap_jobs + +DUMMY_AQ_VALS = { + "datas": "", + "name": "dummy_aq.doc", +} +MOCK_PATH_RUN = ( + "odoo.addons.attachment_queue.models.attachment_queue.AttachmentQueue._run" +) + class TestAttachmentBaseQueue(TransactionCase): + def _create_dummy_attachment(self, override=False, no_job=False): + override = override or {} + vals = DUMMY_AQ_VALS.copy() + vals.update(override) + if no_job: + return ( + self.env["attachment.queue"].with_context(test_queue_job_no_delay=True) + ).create(vals) + return self.env["attachment.queue"].create(vals) + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + from .test_models import AttachmentQueue + + cls.loader.update_registry((AttachmentQueue,)) + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + cls.loader.restore_registry() + return super().tearDownClass() + def setUp(self): super().setUp() - self.registry.enter_test_mode(self.env.cr) - self.env = api.Environment( - self.registry.test_cr, self.env.uid, self.env.context - ) - self.attachment = self.env.ref("attachment_queue.attachment_queue_demo") + self.aq_model = self.env["attachment.queue"] - def tearDown(self): - self.registry.leave_test_mode() - super().tearDown() + def test_job_created(self): + with trap_jobs() as trap: + self._create_dummy_attachment() + trap.assert_enqueued_job( + self.env["attachment.queue"].run, + ) - def test_attachment_queue(self): - """Test run_attachment_queue_scheduler to ensure set state to done""" - self.assertEqual(self.attachment.state, "pending") - self.env["attachment.queue"].run_attachment_queue_scheduler() - self.env.cache.invalidate() - with odoo.registry(self.env.cr.dbname).cursor() as new_cr: - new_env = api.Environment(new_cr, self.env.uid, self.env.context) - attach = self.attachment.with_env(new_env) - self.assertEqual(attach.state, "done") + def test_aq_locked_job(self): + """If an attachment is already running, and a job tries to run it, retry later""" + with self.assertRaises(RetryableJobError): + self._create_dummy_attachment({"running_lock": True}, no_job=True) + + def test_aq_locked_button(self): + """If an attachment is already running, and a user tries to run it manually, + raise error window""" + attachment = self._create_dummy_attachment(no_job=True) + attachment.running_lock = True + with self.assertRaises(UserError): + attachment.button_manual_run() + + def test_run_ok(self): + """Attachment queue should have correct state and result""" + partners_initial = len(self.env["res.partner"].search([])) + with mock.patch.object( + type(self.aq_model), + "_run", + self.env["attachment.queue"].mock_run_create_partners, + ): + attachment = self._create_dummy_attachment(no_job=True) + partners_after = len(self.env["res.partner"].search([])) + self.assertEqual(partners_after, partners_initial + 10) + self.assertEqual(attachment.state, "done") + + def test_run_fails(self): + """Attachment queue should have correct state/error message""" + with mock.patch.object( + type(self.aq_model), "_run", self.env["attachment.queue"].mock_run_fail + ): + attachment = self._create_dummy_attachment(no_job=True) + self.assertEqual(attachment.state, "failed") + self.assertEqual(attachment.state_message, "boom") + + def test_run_fails_rollback(self): + """In case of failure, no side effects should occur""" + partners_initial = len(self.env["res.partner"].search([])) + with mock.patch.object( + type(self.aq_model), + "_run", + self.env["attachment.queue"].mock_run_create_partners_and_fail, + ): + self._create_dummy_attachment(no_job=True) + partners_after = len(self.env["res.partner"].search([])) + self.assertEqual(partners_after, partners_initial) def test_set_done(self): """Test set_done manually""" - self.assertEqual(self.attachment.state, "pending") - self.attachment.set_done() - self.assertEqual(self.attachment.state, "done") + attachment = self._create_dummy_attachment() + self.assertEqual(attachment.state, "pending") + attachment.set_done() + self.assertEqual(attachment.state, "done") diff --git a/attachment_queue/tests/test_models.py b/attachment_queue/tests/test_models.py new file mode 100644 index 000000000..d84c8eaad --- /dev/null +++ b/attachment_queue/tests/test_models.py @@ -0,0 +1,20 @@ +# Copyright 2023 Akretion +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import _, models +from odoo.exceptions import UserError + + +class AttachmentQueue(models.Model): + _inherit = "attachment.queue" + _name = "attachment.queue" + + def mock_run_fail(self): + raise UserError(_("boom")) + + def mock_run_create_partners(self): + for x in range(10): + self.env["res.partner"].create({"name": str(x)}) + + def mock_run_create_partners_and_fail(self): + self.mock_run_create_partners() + raise UserError(_("boom")) diff --git a/attachment_queue/views/attachment_queue_view.xml b/attachment_queue/views/attachment_queue_view.xml index 381099026..89eddcc66 100644 --- a/attachment_queue/views/attachment_queue_view.xml +++ b/attachment_queue/views/attachment_queue_view.xml @@ -9,9 +9,16 @@