From ea273e9adb0b50109228e32f8b7b4cfa4993d854 Mon Sep 17 00:00:00 2001 From: Saran440 Date: Thu, 4 Jun 2026 16:42:27 +0700 Subject: [PATCH] [18.0][ADD] queue_job_is_running --- queue_job_is_running/README.rst | 113 +++++ queue_job_is_running/__init__.py | 3 + queue_job_is_running/__manifest__.py | 17 + queue_job_is_running/models/__init__.py | 4 + queue_job_is_running/models/constants.py | 18 + queue_job_is_running/models/queue_job.py | 31 ++ .../models/queue_job_status_mixin.py | 70 +++ queue_job_is_running/pyproject.toml | 3 + queue_job_is_running/readme/CONTRIBUTORS.md | 1 + queue_job_is_running/readme/DESCRIPTION.md | 6 + queue_job_is_running/readme/USAGE.md | 18 + .../static/description/index.html | 450 ++++++++++++++++++ .../templates/running_job_templates.xml | 15 + queue_job_is_running/tests/__init__.py | 3 + queue_job_is_running/tests/models.py | 20 + .../tests/test_queue_job_status.py | 161 +++++++ test-requirements.txt | 1 + 17 files changed, 934 insertions(+) create mode 100644 queue_job_is_running/README.rst create mode 100644 queue_job_is_running/__init__.py create mode 100644 queue_job_is_running/__manifest__.py create mode 100644 queue_job_is_running/models/__init__.py create mode 100644 queue_job_is_running/models/constants.py create mode 100644 queue_job_is_running/models/queue_job.py create mode 100644 queue_job_is_running/models/queue_job_status_mixin.py create mode 100644 queue_job_is_running/pyproject.toml create mode 100644 queue_job_is_running/readme/CONTRIBUTORS.md create mode 100644 queue_job_is_running/readme/DESCRIPTION.md create mode 100644 queue_job_is_running/readme/USAGE.md create mode 100644 queue_job_is_running/static/description/index.html create mode 100644 queue_job_is_running/templates/running_job_templates.xml create mode 100644 queue_job_is_running/tests/__init__.py create mode 100644 queue_job_is_running/tests/models.py create mode 100644 queue_job_is_running/tests/test_queue_job_status.py create mode 100644 test-requirements.txt diff --git a/queue_job_is_running/README.rst b/queue_job_is_running/README.rst new file mode 100644 index 0000000000..34817740d4 --- /dev/null +++ b/queue_job_is_running/README.rst @@ -0,0 +1,113 @@ +==================== +Queue Job Is 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_is_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_is_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 the ``queue.job.status.mixin`` mixin. It displays a +warning banner on a form view when the record has one or more +non-terminal queue jobs and lists their names. + +The status is read directly from ``queue.job``, so no method is required +to mark a job as running or finished. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Inherit ``queue.job.status.mixin`` and enqueue the job normally: + +.. code:: python + + class MyModel(models.Model): + _name = "my.model" + _inherit = ["queue.job.status.mixin"] + + def action_process(self): + self.with_delay( + description="Process document", + )._process_in_background() + +Set ``description`` to display a user-friendly job name. If it is +omitted, the technical name generated by ``queue_job`` is displayed +instead. + +The banner is visible while the job is pending, enqueued, started, or +waiting for dependencies. It disappears automatically when the job is +done, failed, or cancelled. + +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_is_running/__init__.py b/queue_job_is_running/__init__.py new file mode 100644 index 0000000000..5268169bf6 --- /dev/null +++ b/queue_job_is_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_is_running/__manifest__.py b/queue_job_is_running/__manifest__.py new file mode 100644 index 0000000000..2086fd865f --- /dev/null +++ b/queue_job_is_running/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2026 Ecosoft Co., Ltd. (https://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +{ + "name": "Queue Job Is Running", + "version": "18.0.1.0.0", + "author": "Ecosoft, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/queue", + "summary": "Show the queue jobs running for a record", + "license": "AGPL-3", + "category": "Generic Modules", + "depends": ["queue_job"], + "data": [ + "templates/running_job_templates.xml", + ], + "maintainers": ["Saran440"], +} diff --git a/queue_job_is_running/models/__init__.py b/queue_job_is_running/models/__init__.py new file mode 100644 index 0000000000..78f2d8b3b5 --- /dev/null +++ b/queue_job_is_running/models/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from . import queue_job +from . import queue_job_status_mixin diff --git a/queue_job_is_running/models/constants.py b/queue_job_is_running/models/constants.py new file mode 100644 index 0000000000..fcb378f501 --- /dev/null +++ b/queue_job_is_running/models/constants.py @@ -0,0 +1,18 @@ +# Copyright 2026 Ecosoft Co., Ltd. (https://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo.tools import SQL + +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) + +RUNNING_RECORD_IDS_INDEX = "queue_job_running_record_ids_gin_idx" + +# JobSerialized values created on 18.0 are JSON strings in a jsonb column, while +# migrated values can be JSON objects. Normalize both storage shapes before +# extracting the record ids. +JOB_RECORD_IDS_SQL = SQL( + "COALESCE(records -> 'ids', ((records #>> '{}')::jsonb) -> 'ids')" +) diff --git a/queue_job_is_running/models/queue_job.py b/queue_job_is_running/models/queue_job.py new file mode 100644 index 0000000000..e4760e1408 --- /dev/null +++ b/queue_job_is_running/models/queue_job.py @@ -0,0 +1,31 @@ +# Copyright 2026 Ecosoft Co., Ltd. (https://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import models +from odoo.tools import SQL, index_exists + +from .constants import JOB_RECORD_IDS_SQL, RUNNING_RECORD_IDS_INDEX + + +class QueueJob(models.Model): + _inherit = "queue.job" + + def init(self): + result = super().init() + if not index_exists(self._cr, RUNNING_RECORD_IDS_INDEX): + self._cr.execute( + SQL( + """ + CREATE INDEX %s + ON %s + USING gin ((%s) jsonb_path_ops) + WHERE state IN ( + 'wait_dependencies', 'pending', 'enqueued', 'started' + ) + """, + SQL.identifier(RUNNING_RECORD_IDS_INDEX), + SQL.identifier(self._table), + JOB_RECORD_IDS_SQL, + ) + ) + return result diff --git a/queue_job_is_running/models/queue_job_status_mixin.py b/queue_job_is_running/models/queue_job_status_mixin.py new file mode 100644 index 0000000000..4c25468681 --- /dev/null +++ b/queue_job_is_running/models/queue_job_status_mixin.py @@ -0,0 +1,70 @@ +# Copyright 2026 Ecosoft Co., Ltd. (https://ecosoft.co.th) +# 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.tools import SQL + +from .constants import JOB_RECORD_IDS_SQL, RUNNING_STATES + + +class QueueJobStatusMixin(models.AbstractModel): + _name = "queue.job.status.mixin" + _description = "Queue Job Status Mixin" + + running_job_names = fields.Char(compute="_compute_running_job_names") + + def _compute_running_job_names(self): + """List the non-terminal queue jobs targeting each record.""" + self.running_job_names = False + target_ids = self.ids + if not target_ids: + return + + queue_job = self.env["queue.job"].sudo() + # Raw SQL does not trigger the ORM's automatic field flush. + queue_job.flush_model( + ["model_name", "state", "records", "name", "func_string", "method_name"] + ) + self.env.cr.execute( + SQL( + """ + SELECT target.id, + string_agg( + DISTINCT COALESCE( + job.name, job.func_string, job.method_name + ), + ', ' + ORDER BY COALESCE( + job.name, job.func_string, job.method_name + ) + ) + FROM unnest(%s::integer[]) AS target(id) + JOIN %s AS job + ON %s @> jsonb_build_array(target.id) + WHERE job.model_name = %s + AND job.state = ANY(%s) + GROUP BY target.id + """, + target_ids, + SQL.identifier(queue_job._table), + JOB_RECORD_IDS_SQL, + self._name, + list(RUNNING_STATES), + ) + ) + names_by_record_id = dict(self.env.cr.fetchall()) + for record in self: + record.running_job_names = names_by_record_id.get(record.id, False) + + @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_is_running.running_job_banner", {} + ) + sheet.addprevious(etree.fromstring(label)) + return arch, view diff --git a/queue_job_is_running/pyproject.toml b/queue_job_is_running/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/queue_job_is_running/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/queue_job_is_running/readme/CONTRIBUTORS.md b/queue_job_is_running/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..8d0d33151c --- /dev/null +++ b/queue_job_is_running/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Saran Lim. \<\> diff --git a/queue_job_is_running/readme/DESCRIPTION.md b/queue_job_is_running/readme/DESCRIPTION.md new file mode 100644 index 0000000000..0173e005b1 --- /dev/null +++ b/queue_job_is_running/readme/DESCRIPTION.md @@ -0,0 +1,6 @@ +This addon provides the `queue.job.status.mixin` mixin. It displays a warning banner on +a form view when the record has one or more non-terminal queue jobs and lists their +names. + +The status is read directly from `queue.job`, so no method is required to mark a job as +running or finished. diff --git a/queue_job_is_running/readme/USAGE.md b/queue_job_is_running/readme/USAGE.md new file mode 100644 index 0000000000..ae9781a4ac --- /dev/null +++ b/queue_job_is_running/readme/USAGE.md @@ -0,0 +1,18 @@ +Inherit `queue.job.status.mixin` and enqueue the job normally: + +```python +class MyModel(models.Model): + _name = "my.model" + _inherit = ["queue.job.status.mixin"] + + def action_process(self): + self.with_delay( + description="Process document", + )._process_in_background() +``` + +Set `description` to display a user-friendly job name. If it is omitted, the technical +name generated by `queue_job` is displayed instead. + +The banner is visible while the job is pending, enqueued, started, or waiting for +dependencies. It disappears automatically when the job is done, failed, or cancelled. diff --git a/queue_job_is_running/static/description/index.html b/queue_job_is_running/static/description/index.html new file mode 100644 index 0000000000..d9b231389d --- /dev/null +++ b/queue_job_is_running/static/description/index.html @@ -0,0 +1,450 @@ + + + + + +Queue Job Is Running + + + +
+

Queue Job Is Running

+ + +

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

+

This addon provides the queue.job.status.mixin mixin. It displays a +warning banner on a form view when the record has one or more +non-terminal queue jobs and lists their names.

+

The status is read directly from queue.job, so no method is required +to mark a job as running or finished.

+

Table of contents

+ +
+

Usage

+

Inherit queue.job.status.mixin and enqueue the job normally:

+
+class MyModel(models.Model):
+    _name = "my.model"
+    _inherit = ["queue.job.status.mixin"]
+
+    def action_process(self):
+        self.with_delay(
+            description="Process document",
+        )._process_in_background()
+
+

Set description to display a user-friendly job name. If it is +omitted, the technical name generated by queue_job is displayed +instead.

+

The banner is visible while the job is pending, enqueued, started, or +waiting for dependencies. It disappears automatically when the job is +done, failed, or cancelled.

+
+
+

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_is_running/templates/running_job_templates.xml b/queue_job_is_running/templates/running_job_templates.xml new file mode 100644 index 0000000000..71a9e41b1c --- /dev/null +++ b/queue_job_is_running/templates/running_job_templates.xml @@ -0,0 +1,15 @@ + + + diff --git a/queue_job_is_running/tests/__init__.py b/queue_job_is_running/tests/__init__.py new file mode 100644 index 0000000000..13aa023381 --- /dev/null +++ b/queue_job_is_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_is_running/tests/models.py b/queue_job_is_running/tests/models.py new file mode 100644 index 0000000000..bd6c9993ec --- /dev/null +++ b/queue_job_is_running/tests/models.py @@ -0,0 +1,20 @@ +# Copyright 2026 Ecosoft Co., Ltd. (https://ecosoft.co.th) +# 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_is_running/tests/test_queue_job_status.py b/queue_job_is_running/tests/test_queue_job_status.py new file mode 100644 index 0000000000..b3809916eb --- /dev/null +++ b/queue_job_is_running/tests/test_queue_job_status.py @@ -0,0 +1,161 @@ +# Copyright 2026 Ecosoft Co., Ltd. (https://ecosoft.co.th) +# 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.tools import SQL, index_exists + +from odoo.addons.queue_job.job import ( + CANCELLED, + DONE, + ENQUEUED, + FAILED, + PENDING, + STARTED, + WAIT_DEPENDENCIES, +) +from odoo.addons.queue_job.tests.common import JobMixin +from odoo.addons.queue_job_is_running.models.constants import ( + RUNNING_RECORD_IDS_INDEX, +) + +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 value 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(["running_job_names"]) + return record + + def test_default_and_new_record(self): + self.assertFalse(self._refresh(self.record).running_job_names) + new_record = self.env["queue.running.test.model"].new({"name": "new"}) + with self.assertQueryCount(0): + self.assertFalse(new_record.running_job_names) + + 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.assertEqual( + self._refresh(self.record).running_job_names, + job.name, + ) + + for state in TERMINAL_STATES: + with self.subTest(state=state): + job.state = state + self.assertFalse(self._refresh(self.record).running_job_names) + + def test_unrelated_record_not_flagged(self): + other = self.env["queue.running.test.model"].create({"name": "other"}) + self.record.action_process() + job = self._jobs(self.record) + self.assertEqual(self._refresh(self.record).running_job_names, job.name) + self.assertFalse(self._refresh(other).running_job_names) + + 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() + job = self._jobs(self.record) + self.assertEqual(set(job.records.ids), set(records.ids)) + self.assertEqual( + self._refresh(records).mapped("running_job_names"), + [job.name, job.name], + ) + + def test_multiple_job_names(self): + self.record.with_delay(description="Second job")._process_in_background() + self.record.with_delay(description="First job")._process_in_background() + self.assertEqual( + self._refresh(self.record).running_job_names, + "First job, Second job", + ) + + def test_migrated_json_object_storage(self): + self.record.action_process() + job = self._jobs(self.record) + job.flush_recordset(["records"]) + self.env.cr.execute( + SQL( + """ + UPDATE queue_job + SET records = (records #>> '{}')::jsonb + WHERE id = %s + """, + job.id, + ) + ) + job.invalidate_recordset(["records"]) + self.assertEqual(self._refresh(self.record).running_job_names, job.name) + + def test_compute_uses_one_query(self): + self.record.action_process() + self._refresh(self.record) + with self.assertQueryCount(1): + running_job_names = self.record.running_job_names + self.assertTrue(running_job_names) + + def test_running_record_ids_index_exists(self): + self.assertTrue(index_exists(self.env.cr, RUNNING_RECORD_IDS_INDEX)) + + 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 running_job_names") + self.assertIn("running_job_names", result["models"][self.record._name]) + self.assertEqual( + alerts[0].xpath(".//field")[0].get("name"), + "running_job_names", + ) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000000..4ad8e0ecea --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1 @@ +odoo-test-helper