diff --git a/queue_job_alert_running/README.rst b/queue_job_alert_running/README.rst new file mode 100644 index 000000000..bd9bb8116 --- /dev/null +++ b/queue_job_alert_running/README.rst @@ -0,0 +1,117 @@ +======================= +Job Queue Alert Running +======================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:09e6da1c23c22c23cef1bd7a94382d7d1229403def5111ede114c21ed49ac853 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fqueue-lightgray.png?logo=github + :target: https://github.com/OCA/queue/tree/18.0/queue_job_alert_running + :alt: OCA/queue +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/queue-18-0/queue-18-0-queue_job_alert_running + :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/queue&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This addon provides a mixin (``queue.job.status.mixin``) that adds a +computed ``is_job_running`` boolean field and automatically displays a +warning banner on form views when a record is currently the target of a +non-terminal queue job, helping prevent concurrent editing of the same +record. + +The flag is derived from the actual ``queue.job`` state (pending, +enqueued, started, wait_dependencies), so there is nothing to call +before or after a job: enqueue work with ``with_delay()`` and the banner +reflects reality. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +To use this module: + +1. Inherit ``queue.job.status.mixin`` in your model: + +.. code:: python + + class MyModel(models.Model): + _name = "my.model" + _inherit = ["queue.job.status.mixin"] + + def action_process(self): + self.with_delay()._process_in_background() + +The mixin computes ``is_job_running`` from the real ``queue.job`` state, +so the banner appears while any job targets the record and disappears as +soon as the job reaches a terminal state (done / failed / cancelled) +even on error. + +When ``is_job_running`` is ``True``, a yellow warning banner appears at +the top of the form view: *"This Record is running in queue job."* + +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 +------- + +* Ecosoft + +Contributors +------------ + +- Saran Lim. + +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. + +.. |maintainer-Saran440| image:: https://github.com/Saran440.png?size=40px + :target: https://github.com/Saran440 + :alt: Saran440 + +Current `maintainer `__: + +|maintainer-Saran440| + +This module is part of the `OCA/queue `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/queue_job_alert_running/__init__.py b/queue_job_alert_running/__init__.py new file mode 100644 index 000000000..5268169bf --- /dev/null +++ b/queue_job_alert_running/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from . import models diff --git a/queue_job_alert_running/__manifest__.py b/queue_job_alert_running/__manifest__.py new file mode 100644 index 000000000..6f00aaf94 --- /dev/null +++ b/queue_job_alert_running/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2026 Ecosoft Co., Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +{ + "name": "Job Queue Alert Running", + "version": "18.0.1.0.0", + "author": "Ecosoft, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/queue", + "summary": "Show a warning banner when a record has a running queue job", + "license": "AGPL-3", + "category": "Generic Modules", + "depends": ["queue_job"], + "data": [ + "templates/queue_running_templates.xml", + ], + "maintainers": ["Saran440"], +} diff --git a/queue_job_alert_running/models/__init__.py b/queue_job_alert_running/models/__init__.py new file mode 100644 index 000000000..58e6847e9 --- /dev/null +++ b/queue_job_alert_running/models/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from . import queue_job_status_mixin diff --git a/queue_job_alert_running/models/queue_job_status_mixin.py b/queue_job_alert_running/models/queue_job_status_mixin.py new file mode 100644 index 000000000..3280dcf5f --- /dev/null +++ b/queue_job_alert_running/models/queue_job_status_mixin.py @@ -0,0 +1,56 @@ +# Copyright 2026 Ecosoft Co., Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from lxml import etree + +from odoo import api, fields, models + +from odoo.addons.queue_job.job import ENQUEUED, PENDING, STARTED, WAIT_DEPENDENCIES + +#: queue.job states that are not terminal yet (done / cancelled / failed). +RUNNING_STATES = (WAIT_DEPENDENCIES, PENDING, ENQUEUED, STARTED) + + +class QueueJobStatusMixin(models.AbstractModel): + _name = "queue.job.status.mixin" + _description = "Queue Job Status Mixin" + + is_job_running = fields.Boolean(compute="_compute_is_job_running") + + def _compute_is_job_running(self): + """True when at least one non-terminal queue.job targets this record.""" + target_ids = set(self.ids) + if not target_ids: + self.is_job_running = False + return + + jobs = ( + self.env["queue.job"] + .sudo() + .search_fetch( + [ + ("model_name", "=", self._name), + ("state", "in", RUNNING_STATES), + ], + ["records"], + ) + ) + running_ids = { + record_id + for job in jobs + for record_id in job.records.ids + if record_id in target_ids + } + for record in self: + record.is_job_running = record.id in running_ids + + @api.model + def _get_view(self, view_id=None, view_type="form", **options): + arch, view = super()._get_view(view_id=view_id, view_type=view_type, **options) + if view_type == "form": + for sheet in arch.xpath("/form/sheet"): + label = self.env["ir.qweb"]._render( + "queue_job_alert_running.queue_running_label", {} + ) + sheet.addprevious(etree.fromstring(label)) + return arch, view diff --git a/queue_job_alert_running/pyproject.toml b/queue_job_alert_running/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/queue_job_alert_running/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/queue_job_alert_running/readme/CONTRIBUTORS.md b/queue_job_alert_running/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..8d0d33151 --- /dev/null +++ b/queue_job_alert_running/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Saran Lim. \<\> diff --git a/queue_job_alert_running/readme/DESCRIPTION.md b/queue_job_alert_running/readme/DESCRIPTION.md new file mode 100644 index 000000000..de5b94890 --- /dev/null +++ b/queue_job_alert_running/readme/DESCRIPTION.md @@ -0,0 +1,8 @@ +This addon provides a mixin (`queue.job.status.mixin`) that adds a computed +`is_job_running` boolean field and automatically displays a warning banner on form views +when a record is currently the target of a non-terminal queue job, helping prevent +concurrent editing of the same record. + +The flag is derived from the actual `queue.job` state (pending, enqueued, started, +wait_dependencies), so there is nothing to call before or after a job: enqueue work with +`with_delay()` and the banner reflects reality. diff --git a/queue_job_alert_running/readme/USAGE.md b/queue_job_alert_running/readme/USAGE.md new file mode 100644 index 000000000..bb1e5a957 --- /dev/null +++ b/queue_job_alert_running/readme/USAGE.md @@ -0,0 +1,19 @@ +To use this module: + +1. Inherit `queue.job.status.mixin` in your model: + +```python +class MyModel(models.Model): + _name = "my.model" + _inherit = ["queue.job.status.mixin"] + + def action_process(self): + self.with_delay()._process_in_background() +``` + +The mixin computes `is_job_running` from the real `queue.job` state, so the banner +appears while any job targets the record and disappears as soon as the job reaches a +terminal state (done / failed / cancelled) even on error. + +When `is_job_running` is `True`, a yellow warning banner appears at the top of the form +view: _"This Record is running in queue job."_ diff --git a/queue_job_alert_running/static/description/index.html b/queue_job_alert_running/static/description/index.html new file mode 100644 index 000000000..31f35d6b9 --- /dev/null +++ b/queue_job_alert_running/static/description/index.html @@ -0,0 +1,455 @@ + + + + + +Job Queue Alert Running + + + +
+

Job Queue Alert Running

+ + +

Beta License: AGPL-3 OCA/queue Translate me on Weblate Try me on Runboat

+

This addon provides a mixin (queue.job.status.mixin) that adds a +computed is_job_running boolean field and automatically displays a +warning banner on form views when a record is currently the target of a +non-terminal queue job, helping prevent concurrent editing of the same +record.

+

The flag is derived from the actual queue.job state (pending, +enqueued, started, wait_dependencies), so there is nothing to call +before or after a job: enqueue work with with_delay() and the banner +reflects reality.

+

Table of contents

+ +
+

Usage

+

To use this module:

+
    +
  1. Inherit queue.job.status.mixin in your model:
  2. +
+
+class MyModel(models.Model):
+    _name = "my.model"
+    _inherit = ["queue.job.status.mixin"]
+
+    def action_process(self):
+      self.with_delay()._process_in_background()
+
+

The mixin computes is_job_running from the real queue.job state, +so the banner appears while any job targets the record and disappears as +soon as the job reaches a terminal state (done / failed / cancelled) +even on error.

+

When is_job_running is True, a yellow warning banner appears at +the top of the form view: “This Record is running in queue job.”

+
+
+

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

+
    +
  • Ecosoft
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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.

+

Current maintainer:

+

Saran440

+

This module is part of the OCA/queue project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/queue_job_alert_running/templates/queue_running_templates.xml b/queue_job_alert_running/templates/queue_running_templates.xml new file mode 100644 index 000000000..5a47ba990 --- /dev/null +++ b/queue_job_alert_running/templates/queue_running_templates.xml @@ -0,0 +1,15 @@ + + + + diff --git a/queue_job_alert_running/tests/__init__.py b/queue_job_alert_running/tests/__init__.py new file mode 100644 index 000000000..13aa02338 --- /dev/null +++ b/queue_job_alert_running/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from . import test_queue_job_status diff --git a/queue_job_alert_running/tests/models.py b/queue_job_alert_running/tests/models.py new file mode 100644 index 000000000..6e9536be3 --- /dev/null +++ b/queue_job_alert_running/tests/models.py @@ -0,0 +1,20 @@ +# Copyright 2026 Ecosoft Co., Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import fields, models + + +class QueueRunningTestModel(models.Model): + _name = "queue.running.test.model" + _description = "Queue Running Test Model" + _inherit = ["queue.job.status.mixin"] + + name = fields.Char() + + def action_process(self): + for record in self: + record.with_delay()._process_in_background() + + def _process_in_background(self): + # Do something. + return True diff --git a/queue_job_alert_running/tests/test_queue_job_status.py b/queue_job_alert_running/tests/test_queue_job_status.py new file mode 100644 index 000000000..bafdca408 --- /dev/null +++ b/queue_job_alert_running/tests/test_queue_job_status.py @@ -0,0 +1,110 @@ +# Copyright 2026 Ecosoft Co., Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from lxml import etree +from odoo_test_helper import FakeModelLoader + +from odoo.tests import common + +from odoo.addons.queue_job.job import ( + CANCELLED, + DONE, + ENQUEUED, + FAILED, + PENDING, + STARTED, + WAIT_DEPENDENCIES, +) +from odoo.addons.queue_job.tests.common import JobMixin + +RUNNING_STATES = (WAIT_DEPENDENCIES, PENDING, ENQUEUED, STARTED) +TERMINAL_STATES = (DONE, FAILED, CANCELLED) + + +class TestQueueJobStatus(common.TransactionCase, JobMixin): + def setUp(self): + super().setUp() + # Register the test-only model on the fly so it (and its table) exist + # only for this test run and never ship in production. + self.loader = FakeModelLoader(self.env, self.__module__) + self.loader.backup_registry() + from .models import QueueRunningTestModel + + self.loader.update_registry((QueueRunningTestModel,)) + self.record = self.env["queue.running.test.model"].create({"name": "demo"}) + + def tearDown(self): + self.loader.restore_registry() + super().tearDown() + + def _jobs(self, record): + """Return queue jobs for the model under test.""" + return self.env["queue.job"].search([("model_name", "=", record._name)]) + + def _refresh(self, record): + """Drop the cached computed flag so it is recomputed on next read. + + On the client side the form is reloaded after an action, which gives + a fresh value; in tests we must drop the in-memory cache of the + non-stored computed field, otherwise the previous value sticks. + """ + record.invalidate_recordset(["is_job_running"]) + return record + + def test_default_and_new_record(self): + self.assertFalse(self._refresh(self.record).is_job_running) + new_record = self.env["queue.running.test.model"].new({"name": "new"}) + with self.assertQueryCount(0): + self.assertFalse(new_record.is_job_running) + + def test_job_states(self): + self.record.action_process() + job = self._jobs(self.record) + self.assertEqual(len(job), 1) + + for state in RUNNING_STATES: + with self.subTest(state=state): + job.state = state + self.assertTrue(self._refresh(self.record).is_job_running) + + for state in TERMINAL_STATES: + with self.subTest(state=state): + job.state = state + self.assertFalse(self._refresh(self.record).is_job_running) + + def test_unrelated_record_not_flagged(self): + other = self.env["queue.running.test.model"].create({"name": "other"}) + self.record.action_process() + self.assertTrue(self._refresh(self.record).is_job_running) + self.assertFalse(self._refresh(other).is_job_running) + + def test_multi_record_job(self): + other = self.env["queue.running.test.model"].create({"name": "other"}) + records = self.record | other + records.with_delay()._process_in_background() + self.assertEqual(set(self._jobs(self.record).records.ids), set(records.ids)) + self.assertTrue(all(self._refresh(records).mapped("is_job_running"))) + + def test_form_view_has_running_job_alert(self): + view = self.env["ir.ui.view"].create( + { + "name": "queue.running.test.model.form", + "model": self.record._name, + "arch": """ +
+ + + +
+ """, + } + ) + result = self.record.get_view(view_id=view.id, view_type="form") + arch = etree.fromstring(result["arch"]) + alerts = arch.xpath( + "/form/div[contains(concat(' ', normalize-space(@class), ' '), " + "' alert ')]" + ) + self.assertEqual(len(alerts), 1) + self.assertEqual(alerts[0].get("invisible"), "not is_job_running") + self.assertIn("is_job_running", result["models"][self.record._name]) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 000000000..4ad8e0ece --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1 @@ +odoo-test-helper