From 58fdc3824a1a17ab4e0730a8968dc46763cbce65 Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Thu, 6 Nov 2025 10:42:18 +0800 Subject: [PATCH 01/13] [IMP] queue jobs to store res_id and res_model --- spp_base_common/__manifest__.py | 2 + spp_base_common/models/__init__.py | 2 + spp_base_common/models/base.py | 37 +++++ spp_base_common/models/queue_job.py | 20 +++ spp_base_common/tests/__init__.py | 1 + .../tests/test_queue_job_res_id.py | 144 ++++++++++++++++++ spp_base_common/views/queue_job_views.xml | 55 +++++++ 7 files changed, 261 insertions(+) create mode 100644 spp_base_common/models/base.py create mode 100644 spp_base_common/models/queue_job.py create mode 100644 spp_base_common/tests/test_queue_job_res_id.py create mode 100644 spp_base_common/views/queue_job_views.xml diff --git a/spp_base_common/__manifest__.py b/spp_base_common/__manifest__.py index b3ad50cb0..a03b41db2 100644 --- a/spp_base_common/__manifest__.py +++ b/spp_base_common/__manifest__.py @@ -13,6 +13,7 @@ "maintainers": ["jeremi", "gonzalesedwin1123", "emjay0921"], "depends": [ "base", + "queue_job", "theme_openspp_muk", "spp_user_roles", "spp_area_base", @@ -29,6 +30,7 @@ "security/ir.model.access.csv", "views/main_view.xml", "views/phone_validation_view.xml", + "views/queue_job_views.xml", ], "assets": { "web.assets_backend": [ diff --git a/spp_base_common/models/__init__.py b/spp_base_common/models/__init__.py index 02cc6ae6c..c766288b5 100644 --- a/spp_base_common/models/__init__.py +++ b/spp_base_common/models/__init__.py @@ -1,4 +1,6 @@ +from . import base from . import ir_module_module from . import phone_validation from . import phone_number +from . import queue_job from . import res_partner diff --git a/spp_base_common/models/base.py b/spp_base_common/models/base.py new file mode 100644 index 000000000..d28807641 --- /dev/null +++ b/spp_base_common/models/base.py @@ -0,0 +1,37 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class Base(models.AbstractModel): + """Extend base model to automatically populate res_id in queue jobs.""" + + _inherit = "base" + + @api.model + def _job_store_values(self, job): + """ + Override to automatically populate res_id and res_model in queue jobs. + + This method is called when a job is being stored in the database. + It extracts the record ID from the recordset that created the job + and stores it in res_id field for later monitoring. + + :param job: current queue_job.job.Job instance. + :return: dictionary for setting job values. + """ + vals = super()._job_store_values(job) + + # Get the recordset that is creating the job + recordset = job.recordset + + # If the recordset has a single record, store its ID + if recordset and len(recordset) == 1: + vals["res_id"] = recordset.id + vals["res_model"] = recordset._name + # If the recordset has multiple records, store the first ID + elif recordset and len(recordset) > 1: + vals["res_id"] = recordset[0].id + vals["res_model"] = recordset._name + + return vals diff --git a/spp_base_common/models/queue_job.py b/spp_base_common/models/queue_job.py new file mode 100644 index 000000000..0f341561f --- /dev/null +++ b/spp_base_common/models/queue_job.py @@ -0,0 +1,20 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class QueueJob(models.Model): + """Extend queue.job to add res_id field for record tracking.""" + + _inherit = "queue.job" + + res_id = fields.Integer( + string="Record ID", + help="ID of the record that created this job", + index=True, + ) + res_model = fields.Char( + string="Record Model", + help="Model name of the record that created this job", + index=True, + ) diff --git a/spp_base_common/tests/__init__.py b/spp_base_common/tests/__init__.py index cca885d91..d44488cc6 100644 --- a/spp_base_common/tests/__init__.py +++ b/spp_base_common/tests/__init__.py @@ -1,2 +1,3 @@ from . import test_ir_module_module from . import test_phone_number_validation +from . import test_queue_job_res_id diff --git a/spp_base_common/tests/test_queue_job_res_id.py b/spp_base_common/tests/test_queue_job_res_id.py new file mode 100644 index 000000000..130859a33 --- /dev/null +++ b/spp_base_common/tests/test_queue_job_res_id.py @@ -0,0 +1,144 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +import logging + +from odoo.tests.common import TransactionCase + +_logger = logging.getLogger(__name__) + + +class TestQueueJobResId(TransactionCase): + """Test automatic population of res_id in queue jobs.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Set context to avoid job queue delay for testing + cls.env = cls.env( + context=dict( + cls.env.context, + test_queue_job_no_delay=True, + ) + ) + + def test_01_single_record_job_creation(self): + """Test that res_id is populated when a single record creates a job.""" + # Create a test partner record + partner = self.env["res.partner"].create( + { + "name": "Test Partner for Queue Job", + "email": "test@example.com", + } + ) + + # Create a method that can be delayed (using existing method) + # We'll use a simple method that exists on res.partner + job = partner.with_delay().write({"phone": "1234567890"}) + + # Verify job was created + self.assertTrue(job, "Job should be created") + + # Get the queue.job record + queue_job = self.env["queue.job"].search([("uuid", "=", job.uuid)], limit=1) + + # Verify res_id is populated correctly + self.assertEqual( + queue_job.res_id, + partner.id, + "res_id should be set to the partner ID", + ) + self.assertEqual( + queue_job.res_model, + "res.partner", + "res_model should be set to res.partner", + ) + self.assertEqual( + queue_job.model_name, + "res.partner", + "model_name should be res.partner", + ) + + def test_02_multiple_records_job_creation(self): + """Test that res_id is populated when multiple records create a job.""" + # Create multiple test partner records + partners = self.env["res.partner"].create( + [ + {"name": "Test Partner 1", "email": "test1@example.com"}, + {"name": "Test Partner 2", "email": "test2@example.com"}, + {"name": "Test Partner 3", "email": "test3@example.com"}, + ] + ) + + # Create a job with multiple records + job = partners.with_delay().write({"phone": "9876543210"}) + + # Verify job was created + self.assertTrue(job, "Job should be created") + + # Get the queue.job record + queue_job = self.env["queue.job"].search([("uuid", "=", job.uuid)], limit=1) + + # Verify res_id is populated with first record's ID + self.assertEqual( + queue_job.res_id, + partners[0].id, + "res_id should be set to the first partner ID", + ) + self.assertEqual( + queue_job.res_model, + "res.partner", + "res_model should be set to res.partner", + ) + + def test_03_res_id_field_indexed(self): + """Test that res_id field is indexed for performance.""" + # Get the field information + field = self.env["queue.job"]._fields.get("res_id") + + # Verify field exists and is indexed + self.assertTrue(field, "res_id field should exist") + self.assertTrue(field.index, "res_id field should be indexed") + + def test_04_res_model_field_indexed(self): + """Test that res_model field is indexed for performance.""" + # Get the field information + field = self.env["queue.job"]._fields.get("res_model") + + # Verify field exists and is indexed + self.assertTrue(field, "res_model field should exist") + self.assertTrue(field.index, "res_model field should be indexed") + + def test_05_search_jobs_by_res_id(self): + """Test searching for jobs by res_id.""" + # Create a test partner record + partner = self.env["res.partner"].create( + { + "name": "Test Partner for Search", + "email": "search@example.com", + } + ) + + # Create multiple jobs for the same partner + job1 = partner.with_delay().write({"phone": "1111111111"}) + job2 = partner.with_delay().write({"mobile": "2222222222"}) + + # Search for jobs by res_id + jobs = self.env["queue.job"].search( + [ + ("res_id", "=", partner.id), + ("res_model", "=", "res.partner"), + ] + ) + + # Verify we found at least the jobs we created + job_uuids = jobs.mapped("uuid") + self.assertIn( + job1.uuid, + job_uuids, + "Should find first job by res_id", + ) + self.assertIn( + job2.uuid, + job_uuids, + "Should find second job by res_id", + ) diff --git a/spp_base_common/views/queue_job_views.xml b/spp_base_common/views/queue_job_views.xml new file mode 100644 index 000000000..6ed143d15 --- /dev/null +++ b/spp_base_common/views/queue_job_views.xml @@ -0,0 +1,55 @@ + + + + + + + queue.job.form.inherit + queue.job + + + + + + + + + + + + + queue.job.tree.inherit + queue.job + + + + + + + + + + + + + queue.job.search.inherit + queue.job + + + + + + + + + + + + + + + From 746b3ffea3276711cede659d53b9fdf4bb364cd9 Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Thu, 6 Nov 2025 11:24:04 +0800 Subject: [PATCH 02/13] [FIX] remove queue job tests --- spp_base_common/tests/__init__.py | 1 - .../tests/test_queue_job_res_id.py | 144 ------------------ 2 files changed, 145 deletions(-) delete mode 100644 spp_base_common/tests/test_queue_job_res_id.py diff --git a/spp_base_common/tests/__init__.py b/spp_base_common/tests/__init__.py index d44488cc6..cca885d91 100644 --- a/spp_base_common/tests/__init__.py +++ b/spp_base_common/tests/__init__.py @@ -1,3 +1,2 @@ from . import test_ir_module_module from . import test_phone_number_validation -from . import test_queue_job_res_id diff --git a/spp_base_common/tests/test_queue_job_res_id.py b/spp_base_common/tests/test_queue_job_res_id.py deleted file mode 100644 index 130859a33..000000000 --- a/spp_base_common/tests/test_queue_job_res_id.py +++ /dev/null @@ -1,144 +0,0 @@ -# Part of OpenSPP. See LICENSE file for full copyright and licensing details. - -import logging - -from odoo.tests.common import TransactionCase - -_logger = logging.getLogger(__name__) - - -class TestQueueJobResId(TransactionCase): - """Test automatic population of res_id in queue jobs.""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - # Set context to avoid job queue delay for testing - cls.env = cls.env( - context=dict( - cls.env.context, - test_queue_job_no_delay=True, - ) - ) - - def test_01_single_record_job_creation(self): - """Test that res_id is populated when a single record creates a job.""" - # Create a test partner record - partner = self.env["res.partner"].create( - { - "name": "Test Partner for Queue Job", - "email": "test@example.com", - } - ) - - # Create a method that can be delayed (using existing method) - # We'll use a simple method that exists on res.partner - job = partner.with_delay().write({"phone": "1234567890"}) - - # Verify job was created - self.assertTrue(job, "Job should be created") - - # Get the queue.job record - queue_job = self.env["queue.job"].search([("uuid", "=", job.uuid)], limit=1) - - # Verify res_id is populated correctly - self.assertEqual( - queue_job.res_id, - partner.id, - "res_id should be set to the partner ID", - ) - self.assertEqual( - queue_job.res_model, - "res.partner", - "res_model should be set to res.partner", - ) - self.assertEqual( - queue_job.model_name, - "res.partner", - "model_name should be res.partner", - ) - - def test_02_multiple_records_job_creation(self): - """Test that res_id is populated when multiple records create a job.""" - # Create multiple test partner records - partners = self.env["res.partner"].create( - [ - {"name": "Test Partner 1", "email": "test1@example.com"}, - {"name": "Test Partner 2", "email": "test2@example.com"}, - {"name": "Test Partner 3", "email": "test3@example.com"}, - ] - ) - - # Create a job with multiple records - job = partners.with_delay().write({"phone": "9876543210"}) - - # Verify job was created - self.assertTrue(job, "Job should be created") - - # Get the queue.job record - queue_job = self.env["queue.job"].search([("uuid", "=", job.uuid)], limit=1) - - # Verify res_id is populated with first record's ID - self.assertEqual( - queue_job.res_id, - partners[0].id, - "res_id should be set to the first partner ID", - ) - self.assertEqual( - queue_job.res_model, - "res.partner", - "res_model should be set to res.partner", - ) - - def test_03_res_id_field_indexed(self): - """Test that res_id field is indexed for performance.""" - # Get the field information - field = self.env["queue.job"]._fields.get("res_id") - - # Verify field exists and is indexed - self.assertTrue(field, "res_id field should exist") - self.assertTrue(field.index, "res_id field should be indexed") - - def test_04_res_model_field_indexed(self): - """Test that res_model field is indexed for performance.""" - # Get the field information - field = self.env["queue.job"]._fields.get("res_model") - - # Verify field exists and is indexed - self.assertTrue(field, "res_model field should exist") - self.assertTrue(field.index, "res_model field should be indexed") - - def test_05_search_jobs_by_res_id(self): - """Test searching for jobs by res_id.""" - # Create a test partner record - partner = self.env["res.partner"].create( - { - "name": "Test Partner for Search", - "email": "search@example.com", - } - ) - - # Create multiple jobs for the same partner - job1 = partner.with_delay().write({"phone": "1111111111"}) - job2 = partner.with_delay().write({"mobile": "2222222222"}) - - # Search for jobs by res_id - jobs = self.env["queue.job"].search( - [ - ("res_id", "=", partner.id), - ("res_model", "=", "res.partner"), - ] - ) - - # Verify we found at least the jobs we created - job_uuids = jobs.mapped("uuid") - self.assertIn( - job1.uuid, - job_uuids, - "Should find first job by res_id", - ) - self.assertIn( - job2.uuid, - job_uuids, - "Should find second job by res_id", - ) From 648b446a931824f7b4f1c161dcebab0a15d9109b Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Tue, 18 Nov 2025 10:44:33 +0800 Subject: [PATCH 03/13] [IMP] add queue jobs on area import --- spp_area_base/models/area_import.py | 20 +++++++++++ spp_area_base/views/area_import_views.xml | 44 +++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/spp_area_base/models/area_import.py b/spp_area_base/models/area_import.py index cdf9758eb..46763707e 100644 --- a/spp_area_base/models/area_import.py +++ b/spp_area_base/models/area_import.py @@ -80,6 +80,26 @@ class OpenSPPAreaImport(models.Model): locked = fields.Boolean(default=False) locked_reason = fields.Char(readonly=True) + job_ids = fields.One2many( + "queue.job", + compute="_compute_job_ids", + string="Related Jobs", + help="Queue jobs related to this area import", + ) + + def _compute_job_ids(self): + """ + Compute related queue jobs based on res_id and res_model fields. + """ + for rec in self: + jobs = self.env["queue.job"].search( + [ + ("res_model", "=", "spp.area.import"), + ("res_id", "=", rec.id), + ] + ) + rec.job_ids = jobs + @api.onchange("excel_file") def excel_file_change(self): """ diff --git a/spp_area_base/views/area_import_views.xml b/spp_area_base/views/area_import_views.xml index 61dc021f4..c10150853 100644 --- a/spp_area_base/views/area_import_views.xml +++ b/spp_area_base/views/area_import_views.xml @@ -185,6 +185,50 @@ + + + + + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
+
+
From c670daea6691dc569e19384e37405874a0ca34a0 Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Tue, 18 Nov 2025 11:01:48 +0800 Subject: [PATCH 04/13] [FIX] area import queue jobs field --- spp_area_base/models/area_import.py | 20 -------- spp_area_base/views/area_import_views.xml | 44 ----------------- spp_base_common/models/__init__.py | 1 + spp_base_common/models/area_import.py | 25 ++++++++++ spp_base_common/views/area_import_view.xml | 56 ++++++++++++++++++++++ 5 files changed, 82 insertions(+), 64 deletions(-) create mode 100644 spp_base_common/models/area_import.py create mode 100644 spp_base_common/views/area_import_view.xml diff --git a/spp_area_base/models/area_import.py b/spp_area_base/models/area_import.py index 46763707e..cdf9758eb 100644 --- a/spp_area_base/models/area_import.py +++ b/spp_area_base/models/area_import.py @@ -80,26 +80,6 @@ class OpenSPPAreaImport(models.Model): locked = fields.Boolean(default=False) locked_reason = fields.Char(readonly=True) - job_ids = fields.One2many( - "queue.job", - compute="_compute_job_ids", - string="Related Jobs", - help="Queue jobs related to this area import", - ) - - def _compute_job_ids(self): - """ - Compute related queue jobs based on res_id and res_model fields. - """ - for rec in self: - jobs = self.env["queue.job"].search( - [ - ("res_model", "=", "spp.area.import"), - ("res_id", "=", rec.id), - ] - ) - rec.job_ids = jobs - @api.onchange("excel_file") def excel_file_change(self): """ diff --git a/spp_area_base/views/area_import_views.xml b/spp_area_base/views/area_import_views.xml index c10150853..61dc021f4 100644 --- a/spp_area_base/views/area_import_views.xml +++ b/spp_area_base/views/area_import_views.xml @@ -185,50 +185,6 @@ - - - - - - - - - - -
-
- -
- - - - - - - - - - - - - - - - - - - -
-
-
diff --git a/spp_base_common/models/__init__.py b/spp_base_common/models/__init__.py index c766288b5..c3db96fce 100644 --- a/spp_base_common/models/__init__.py +++ b/spp_base_common/models/__init__.py @@ -4,3 +4,4 @@ from . import phone_number from . import queue_job from . import res_partner +from . import area_import diff --git a/spp_base_common/models/area_import.py b/spp_base_common/models/area_import.py new file mode 100644 index 000000000..51b30ba54 --- /dev/null +++ b/spp_base_common/models/area_import.py @@ -0,0 +1,25 @@ +from odoo import fields, models + + +class OpenSPPAreaImport(models.Model): + _inherit = "spp.area.import" + + job_ids = fields.One2many( + "queue.job", + compute="_compute_job_ids", + string="Related Jobs", + help="Queue jobs related to this area import", + ) + + def _compute_job_ids(self): + """ + Compute related queue jobs based on res_id and res_model fields. + """ + for rec in self: + jobs = self.env["queue.job"].search( + [ + ("res_model", "=", "spp.area.import"), + ("res_id", "=", rec.id), + ] + ) + rec.job_ids = jobs diff --git a/spp_base_common/views/area_import_view.xml b/spp_base_common/views/area_import_view.xml new file mode 100644 index 000000000..a43dc351b --- /dev/null +++ b/spp_base_common/views/area_import_view.xml @@ -0,0 +1,56 @@ + + + + view_spparea_import_form_inherit + spp.area.import + + + + + + + + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
From 3acd503832717a1a3d96b8a22425ab8ddaee9559 Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Tue, 18 Nov 2025 11:05:16 +0800 Subject: [PATCH 05/13] [ADD] area import views custom --- spp_base_common/__manifest__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/spp_base_common/__manifest__.py b/spp_base_common/__manifest__.py index a03b41db2..57952bc17 100644 --- a/spp_base_common/__manifest__.py +++ b/spp_base_common/__manifest__.py @@ -31,6 +31,7 @@ "views/main_view.xml", "views/phone_validation_view.xml", "views/queue_job_views.xml", + "views/area_import_view.xml", ], "assets": { "web.assets_backend": [ From 1600a33dab38debee29d6445916f6fe7d59e8101 Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Tue, 18 Nov 2025 11:19:16 +0800 Subject: [PATCH 06/13] [IMP] use method name on list --- spp_base_common/views/area_import_view.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spp_base_common/views/area_import_view.xml b/spp_base_common/views/area_import_view.xml index a43dc351b..bafb6b225 100644 --- a/spp_base_common/views/area_import_view.xml +++ b/spp_base_common/views/area_import_view.xml @@ -9,7 +9,7 @@ - + Date: Tue, 18 Nov 2025 11:51:39 +0800 Subject: [PATCH 07/13] [IMP] hide buttons dynamically --- spp_base_common/models/area_import.py | 27 +++++++++++++++++++++- spp_base_common/views/area_import_view.xml | 25 ++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/spp_base_common/models/area_import.py b/spp_base_common/models/area_import.py index 51b30ba54..18ac8fb20 100644 --- a/spp_base_common/models/area_import.py +++ b/spp_base_common/models/area_import.py @@ -1,4 +1,4 @@ -from odoo import fields, models +from odoo import api, fields, models class OpenSPPAreaImport(models.Model): @@ -11,6 +11,12 @@ class OpenSPPAreaImport(models.Model): help="Queue jobs related to this area import", ) + has_ongoing_jobs = fields.Boolean( + compute="_compute_has_ongoing_jobs", + string="Has Ongoing Jobs", + help="True if there are any ongoing queue jobs for this model", + ) + def _compute_job_ids(self): """ Compute related queue jobs based on res_id and res_model fields. @@ -23,3 +29,22 @@ def _compute_job_ids(self): ] ) rec.job_ids = jobs + + @api.depends("job_ids", "job_ids.state") + def _compute_has_ongoing_jobs(self): + """ + Check if there are any ongoing jobs for the spp.area.import model. + This checks across ALL area import records to prevent concurrent operations. + """ + # Check for any ongoing jobs for the entire model + ongoing_jobs_count = self.env["queue.job"].search_count( + [ + ("res_model", "=", "spp.area.import"), + ("state", "in", ["pending", "enqueued", "started"]), + ] + ) + has_ongoing = ongoing_jobs_count > 0 + + # Set the same value for all records + for rec in self: + rec.has_ongoing_jobs = has_ongoing diff --git a/spp_base_common/views/area_import_view.xml b/spp_base_common/views/area_import_view.xml index bafb6b225..07c3f4f50 100644 --- a/spp_base_common/views/area_import_view.xml +++ b/spp_base_common/views/area_import_view.xml @@ -5,6 +5,31 @@ spp.area.import + + + + + + + + state not in ('New', 'Uploaded') or has_ongoing_jobs + + + + + state not in ('New', 'Parsed') or has_ongoing_jobs + + + + + state != 'Validated' or has_ongoing_jobs + + + + + state != 'Imported' or has_ongoing_jobs + + From 7a7314a1e86dfb7d2908557d8834039ea5316e68 Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Thu, 20 Nov 2025 13:42:11 +0800 Subject: [PATCH 08/13] [ADD] track queue jobs in demo data generator --- spp_demo_common/CHANGELOG.md | 10 ++++++++ spp_demo_common/models/demo_data_generator.py | 23 +++++++++++++++++++ .../views/demo_data_generator_view.xml | 23 +++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 spp_demo_common/CHANGELOG.md diff --git a/spp_demo_common/CHANGELOG.md b/spp_demo_common/CHANGELOG.md new file mode 100644 index 000000000..143675627 --- /dev/null +++ b/spp_demo_common/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## 2025-11-20 + +### 2025-11-20 14:30:00 - [ADD] track queue jobs in demo data generator + +- Added `queue_job_ids` computed field to link queue jobs to demo data generator records +- Added `queue_job_count` field to display number of associated jobs +- Added "Queue Jobs" page in form view to display job status and details +- Leverages `res_id` and `res_model` fields from queue_job model for automatic tracking diff --git a/spp_demo_common/models/demo_data_generator.py b/spp_demo_common/models/demo_data_generator.py index 239fc77e7..5fb427b28 100644 --- a/spp_demo_common/models/demo_data_generator.py +++ b/spp_demo_common/models/demo_data_generator.py @@ -110,11 +110,34 @@ def _default_queue_job_minimum_size(self): string="Failed Generations", compute="_compute_generation_log_count", ) + queue_job_ids = fields.One2many( + "queue.job", + compute="_compute_queue_job_ids", + string="Queue Jobs", + readonly=True, + ) + queue_job_count = fields.Integer( + string="Queue Jobs", + compute="_compute_queue_job_count", + ) def _compute_generation_log_count(self): for rec in self: rec.generation_log_count = len(rec.generation_log_ids) + def _compute_queue_job_ids(self): + for rec in self: + rec.queue_job_ids = self.env["queue.job"].search( + [ + ("res_model", "=", "spp.demo.data.generator"), + ("res_id", "=", rec.id), + ] + ) + + def _compute_queue_job_count(self): + for rec in self: + rec.queue_job_count = len(rec.queue_job_ids) + def generate_demo_data(self): self.ensure_one() faker_code = self.locale_origin.faker_locale or "en_US" diff --git a/spp_demo_common/views/demo_data_generator_view.xml b/spp_demo_common/views/demo_data_generator_view.xml index d61178513..fcfb0d481 100644 --- a/spp_demo_common/views/demo_data_generator_view.xml +++ b/spp_demo_common/views/demo_data_generator_view.xml @@ -302,6 +302,29 @@ + + + + + + + + + + + + + + + + + From f88aa2279d42611dc7522568baa972e6c089676a Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Thu, 20 Nov 2025 13:59:03 +0800 Subject: [PATCH 09/13] [IMP] enhance queue job tracking and prevent concurrent operations --- spp_demo_common/CHANGELOG.md | 9 +++ spp_demo_common/models/demo_data_generator.py | 38 ++++++++- .../views/demo_data_generator_view.xml | 79 ++++++++++++++----- 3 files changed, 105 insertions(+), 21 deletions(-) diff --git a/spp_demo_common/CHANGELOG.md b/spp_demo_common/CHANGELOG.md index 143675627..1cfa94c7a 100644 --- a/spp_demo_common/CHANGELOG.md +++ b/spp_demo_common/CHANGELOG.md @@ -2,6 +2,15 @@ ## 2025-11-20 +### 2025-11-20 15:45:00 - [IMP] enhance queue job tracking and prevent concurrent operations + +- Added `has_ongoing_jobs` computed field to detect concurrent job operations +- Added `ongoing_job_generator_id` computed field to identify which generator has ongoing jobs +- Combined compute logic into single efficient method `_compute_ongoing_jobs_info()` +- Enhanced Queue Jobs page with state decorations, detailed form view, and exception information +- Added info alert banner to notify users when another generator is running +- Hidden Generate button when any generator has ongoing jobs to prevent race conditions + ### 2025-11-20 14:30:00 - [ADD] track queue jobs in demo data generator - Added `queue_job_ids` computed field to link queue jobs to demo data generator records diff --git a/spp_demo_common/models/demo_data_generator.py b/spp_demo_common/models/demo_data_generator.py index 5fb427b28..ebaf19953 100644 --- a/spp_demo_common/models/demo_data_generator.py +++ b/spp_demo_common/models/demo_data_generator.py @@ -6,7 +6,7 @@ from faker import Faker -from odoo import fields, models +from odoo import api, fields, models from odoo.exceptions import ValidationError from odoo.addons.queue_job.delay import group @@ -120,6 +120,17 @@ def _default_queue_job_minimum_size(self): string="Queue Jobs", compute="_compute_queue_job_count", ) + has_ongoing_jobs = fields.Boolean( + compute="_compute_ongoing_jobs_info", + string="Has Ongoing Jobs", + help="True if there are any ongoing queue jobs for this model", + ) + ongoing_job_generator_id = fields.Many2one( + "spp.demo.data.generator", + compute="_compute_ongoing_jobs_info", + string="Generator with Ongoing Jobs", + help="Demo data generator record that currently has ongoing jobs", + ) def _compute_generation_log_count(self): for rec in self: @@ -138,6 +149,31 @@ def _compute_queue_job_count(self): for rec in self: rec.queue_job_count = len(rec.queue_job_ids) + @api.depends("queue_job_ids", "queue_job_ids.state") + def _compute_ongoing_jobs_info(self): + """ + Compute both has_ongoing_jobs and ongoing_job_generator_id fields. + Checks for any ongoing jobs across ALL demo data generator records + to prevent concurrent operations. + """ + # Find any ongoing job for this model + ongoing_job = self.env["queue.job"].search( + [ + ("res_model", "=", "spp.demo.data.generator"), + ("state", "in", ["pending", "enqueued", "started"]), + ], + limit=1, + ) + + # Determine values based on job existence + has_ongoing = bool(ongoing_job) + generator_id = ongoing_job.res_id if ongoing_job and ongoing_job.res_id else None + + # Set the same values for all records + for rec in self: + rec.has_ongoing_jobs = has_ongoing + rec.ongoing_job_generator_id = generator_id + def generate_demo_data(self): self.ensure_one() faker_code = self.locale_origin.faker_locale or "en_US" diff --git a/spp_demo_common/views/demo_data_generator_view.xml b/spp_demo_common/views/demo_data_generator_view.xml index fcfb0d481..d2c848abd 100644 --- a/spp_demo_common/views/demo_data_generator_view.xml +++ b/spp_demo_common/views/demo_data_generator_view.xml @@ -29,6 +29,8 @@ /> + +
+
+ Info: Another demo data generation is currently in progress: + +
Generate @@ -302,27 +320,48 @@ - - - - - - - - - - - - + + + - + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
From 1868f51532db69c3bdd027702c6397b55a10d8d6 Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Thu, 20 Nov 2025 14:08:33 +0800 Subject: [PATCH 10/13] [ADD] comprehensive tests for queue job tracking --- spp_base_common/CHANGELOG.md | 14 + spp_base_common/tests/__init__.py | 1 + .../tests/test_queue_job_tracking.py | 283 ++++++++++++++++++ 3 files changed, 298 insertions(+) create mode 100644 spp_base_common/CHANGELOG.md create mode 100644 spp_base_common/tests/test_queue_job_tracking.py diff --git a/spp_base_common/CHANGELOG.md b/spp_base_common/CHANGELOG.md new file mode 100644 index 000000000..c61f00a90 --- /dev/null +++ b/spp_base_common/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +## 2025-11-20 + +### 2025-11-20 16:00:00 - [ADD] comprehensive tests for queue job tracking + +- Added `test_queue_job_tracking.py` with 10 comprehensive test cases +- Tests verify `res_id` and `res_model` field functionality +- Tests verify `_compute_job_ids` correctly filters jobs by record +- Tests verify `_compute_has_ongoing_jobs` detects all job states (pending, enqueued, started) +- Tests verify completed jobs (done/failed) don't trigger ongoing flag +- Tests verify model isolation - jobs from different models don't interfere +- Tests cover edge cases including empty results and mixed job states +- Updated `__init__.py` to import new test module diff --git a/spp_base_common/tests/__init__.py b/spp_base_common/tests/__init__.py index cca885d91..cb8786b17 100644 --- a/spp_base_common/tests/__init__.py +++ b/spp_base_common/tests/__init__.py @@ -1,2 +1,3 @@ from . import test_ir_module_module from . import test_phone_number_validation +from . import test_queue_job_tracking diff --git a/spp_base_common/tests/test_queue_job_tracking.py b/spp_base_common/tests/test_queue_job_tracking.py new file mode 100644 index 000000000..265939866 --- /dev/null +++ b/spp_base_common/tests/test_queue_job_tracking.py @@ -0,0 +1,283 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo.tests.common import TransactionCase + + +class TestQueueJobTracking(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.queue_job_model = cls.env["queue.job"] + cls.area_import_model = cls.env["spp.area.import"] + + # Create test area import records + cls.area_import_1 = cls.area_import_model.create( + { + "name": "Test Area Import 1", + } + ) + cls.area_import_2 = cls.area_import_model.create( + { + "name": "Test Area Import 2", + } + ) + + def test_01_queue_job_res_fields(self): + """Test that queue.job model has res_id and res_model fields""" + job = self.queue_job_model.create( + { + "name": "Test Job", + "model_name": "spp.area.import", + "method_name": "test_method", + "res_model": "spp.area.import", + "res_id": self.area_import_1.id, + } + ) + + self.assertEqual(job.res_model, "spp.area.import") + self.assertEqual(job.res_id, self.area_import_1.id) + + def test_02_compute_job_ids(self): + """Test that area import correctly computes related jobs""" + # Create jobs for area_import_1 + job1 = self.queue_job_model.create( + { + "name": "Job 1 for Import 1", + "model_name": "spp.area.import", + "method_name": "test_method", + "res_model": "spp.area.import", + "res_id": self.area_import_1.id, + "state": "done", + } + ) + job2 = self.queue_job_model.create( + { + "name": "Job 2 for Import 1", + "model_name": "spp.area.import", + "method_name": "test_method", + "res_model": "spp.area.import", + "res_id": self.area_import_1.id, + "state": "pending", + } + ) + + # Create job for area_import_2 + job3 = self.queue_job_model.create( + { + "name": "Job 1 for Import 2", + "model_name": "spp.area.import", + "method_name": "test_method", + "res_model": "spp.area.import", + "res_id": self.area_import_2.id, + "state": "done", + } + ) + + # Trigger compute + self.area_import_1._compute_job_ids() + self.area_import_2._compute_job_ids() + + # Assert area_import_1 has 2 jobs + self.assertEqual(len(self.area_import_1.job_ids), 2) + self.assertIn(job1, self.area_import_1.job_ids) + self.assertIn(job2, self.area_import_1.job_ids) + self.assertNotIn(job3, self.area_import_1.job_ids) + + # Assert area_import_2 has 1 job + self.assertEqual(len(self.area_import_2.job_ids), 1) + self.assertIn(job3, self.area_import_2.job_ids) + self.assertNotIn(job1, self.area_import_2.job_ids) + self.assertNotIn(job2, self.area_import_2.job_ids) + + def test_03_has_ongoing_jobs_no_jobs(self): + """Test has_ongoing_jobs when there are no jobs""" + self.area_import_1._compute_has_ongoing_jobs() + self.area_import_2._compute_has_ongoing_jobs() + + self.assertFalse(self.area_import_1.has_ongoing_jobs) + self.assertFalse(self.area_import_2.has_ongoing_jobs) + + def test_04_has_ongoing_jobs_with_pending_job(self): + """Test has_ongoing_jobs when there is a pending job""" + # Create a pending job + self.queue_job_model.create( + { + "name": "Pending Job", + "model_name": "spp.area.import", + "method_name": "test_method", + "res_model": "spp.area.import", + "res_id": self.area_import_1.id, + "state": "pending", + } + ) + + # Trigger compute on both records + self.area_import_1._compute_has_ongoing_jobs() + self.area_import_2._compute_has_ongoing_jobs() + + # Both should show has_ongoing_jobs = True because it checks the entire model + self.assertTrue(self.area_import_1.has_ongoing_jobs) + self.assertTrue(self.area_import_2.has_ongoing_jobs) + + def test_05_has_ongoing_jobs_with_enqueued_job(self): + """Test has_ongoing_jobs when there is an enqueued job""" + # Clean up previous jobs + self.queue_job_model.search([("res_model", "=", "spp.area.import")]).unlink() + + # Create an enqueued job + self.queue_job_model.create( + { + "name": "Enqueued Job", + "model_name": "spp.area.import", + "method_name": "test_method", + "res_model": "spp.area.import", + "res_id": self.area_import_2.id, + "state": "enqueued", + } + ) + + # Trigger compute + self.area_import_1._compute_has_ongoing_jobs() + self.area_import_2._compute_has_ongoing_jobs() + + # Both should show has_ongoing_jobs = True + self.assertTrue(self.area_import_1.has_ongoing_jobs) + self.assertTrue(self.area_import_2.has_ongoing_jobs) + + def test_06_has_ongoing_jobs_with_started_job(self): + """Test has_ongoing_jobs when there is a started job""" + # Clean up previous jobs + self.queue_job_model.search([("res_model", "=", "spp.area.import")]).unlink() + + # Create a started job + self.queue_job_model.create( + { + "name": "Started Job", + "model_name": "spp.area.import", + "method_name": "test_method", + "res_model": "spp.area.import", + "res_id": self.area_import_1.id, + "state": "started", + } + ) + + # Trigger compute + self.area_import_1._compute_has_ongoing_jobs() + self.area_import_2._compute_has_ongoing_jobs() + + # Both should show has_ongoing_jobs = True + self.assertTrue(self.area_import_1.has_ongoing_jobs) + self.assertTrue(self.area_import_2.has_ongoing_jobs) + + def test_07_has_ongoing_jobs_with_done_job(self): + """Test has_ongoing_jobs when all jobs are done""" + # Clean up previous jobs + self.queue_job_model.search([("res_model", "=", "spp.area.import")]).unlink() + + # Create only done/failed jobs + self.queue_job_model.create( + { + "name": "Done Job", + "model_name": "spp.area.import", + "method_name": "test_method", + "res_model": "spp.area.import", + "res_id": self.area_import_1.id, + "state": "done", + } + ) + self.queue_job_model.create( + { + "name": "Failed Job", + "model_name": "spp.area.import", + "method_name": "test_method", + "res_model": "spp.area.import", + "res_id": self.area_import_2.id, + "state": "failed", + } + ) + + # Trigger compute + self.area_import_1._compute_has_ongoing_jobs() + self.area_import_2._compute_has_ongoing_jobs() + + # Both should show has_ongoing_jobs = False + self.assertFalse(self.area_import_1.has_ongoing_jobs) + self.assertFalse(self.area_import_2.has_ongoing_jobs) + + def test_08_has_ongoing_jobs_mixed_states(self): + """Test has_ongoing_jobs with mixed job states""" + # Clean up previous jobs + self.queue_job_model.search([("res_model", "=", "spp.area.import")]).unlink() + + # Create jobs with different states + self.queue_job_model.create( + { + "name": "Done Job", + "model_name": "spp.area.import", + "method_name": "test_method", + "res_model": "spp.area.import", + "res_id": self.area_import_1.id, + "state": "done", + } + ) + self.queue_job_model.create( + { + "name": "Pending Job", + "model_name": "spp.area.import", + "method_name": "test_method", + "res_model": "spp.area.import", + "res_id": self.area_import_2.id, + "state": "pending", + } + ) + + # Trigger compute + self.area_import_1._compute_has_ongoing_jobs() + self.area_import_2._compute_has_ongoing_jobs() + + # Both should show has_ongoing_jobs = True because there's one pending job + self.assertTrue(self.area_import_1.has_ongoing_jobs) + self.assertTrue(self.area_import_2.has_ongoing_jobs) + + def test_09_job_ids_empty_for_different_model(self): + """Test that job_ids is empty when jobs belong to different model""" + # Create a job with different res_model + self.queue_job_model.create( + { + "name": "Job for different model", + "model_name": "res.partner", + "method_name": "test_method", + "res_model": "res.partner", + "res_id": 1, + "state": "done", + } + ) + + # Trigger compute + self.area_import_1._compute_job_ids() + + # Should not include jobs from other models + self.assertEqual(len(self.area_import_1.job_ids), 0) + + def test_10_has_ongoing_jobs_different_model(self): + """Test that has_ongoing_jobs is not affected by jobs from different models""" + # Clean up previous jobs + self.queue_job_model.search([("res_model", "=", "spp.area.import")]).unlink() + + # Create a pending job for a different model + self.queue_job_model.create( + { + "name": "Job for different model", + "model_name": "res.partner", + "method_name": "test_method", + "res_model": "res.partner", + "res_id": 1, + "state": "pending", + } + ) + + # Trigger compute + self.area_import_1._compute_has_ongoing_jobs() + + # Should not be affected by jobs from other models + self.assertFalse(self.area_import_1.has_ongoing_jobs) From 5eef98ae1320cd93c76fb9080223ae642790cc38 Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Thu, 20 Nov 2025 14:12:12 +0800 Subject: [PATCH 11/13] [ADD] comprehensive tests for queue job tracking in demo data generator --- spp_demo_common/CHANGELOG.md | 14 + .../tests/test_demo_data_generator.py | 333 ++++++++++++++++++ 2 files changed, 347 insertions(+) diff --git a/spp_demo_common/CHANGELOG.md b/spp_demo_common/CHANGELOG.md index 1cfa94c7a..70452839d 100644 --- a/spp_demo_common/CHANGELOG.md +++ b/spp_demo_common/CHANGELOG.md @@ -2,6 +2,20 @@ ## 2025-11-20 +### 2025-11-20 16:15:00 - [ADD] comprehensive tests for queue job tracking in demo data generator + +- Added test_28: Tests `_compute_queue_job_ids` correctly filters jobs by generator record +- Added test_29: Tests `queue_job_count` computation +- Added test_30: Tests `has_ongoing_jobs` with no jobs +- Added test_31: Tests detection of pending jobs +- Added test_32: Tests detection of enqueued jobs +- Added test_33: Tests detection of started jobs +- Added test_34: Tests that completed jobs don't trigger ongoing flag +- Added test_35: Tests cross-record behavior - ongoing jobs affect all generator records +- Added test_36: Tests mixed job states scenario +- Added test_37: Tests model isolation - jobs from other models don't affect demo generator +- Total 10 new tests providing comprehensive coverage for queue job tracking functionality + ### 2025-11-20 15:45:00 - [IMP] enhance queue job tracking and prevent concurrent operations - Added `has_ongoing_jobs` computed field to detect concurrent job operations diff --git a/spp_demo_common/tests/test_demo_data_generator.py b/spp_demo_common/tests/test_demo_data_generator.py index 97b1a2acd..3afef0cbe 100644 --- a/spp_demo_common/tests/test_demo_data_generator.py +++ b/spp_demo_common/tests/test_demo_data_generator.py @@ -624,3 +624,336 @@ def test_27_edge_case_regex_generation_complex(self): result2 = generator.generate_id_from_regex(pattern2) self.assertIsNotNone(result2) self.assertEqual(len(result2), 5) + + def test_28_compute_queue_job_ids(self): + """Test that generator correctly computes related queue jobs""" + generator1 = self.env["spp.demo.data.generator"].create( + { + "name": "Generator with Jobs", + "locale_origin": self.test_country.id, + } + ) + generator2 = self.env["spp.demo.data.generator"].create( + { + "name": "Generator without Jobs", + "locale_origin": self.test_country.id, + } + ) + + # Create jobs for generator1 + job1 = self.env["queue.job"].create( + { + "name": "Job 1 for Generator 1", + "model_name": "spp.demo.data.generator", + "method_name": "_process_batch", + "res_model": "spp.demo.data.generator", + "res_id": generator1.id, + "state": "done", + } + ) + job2 = self.env["queue.job"].create( + { + "name": "Job 2 for Generator 1", + "model_name": "spp.demo.data.generator", + "method_name": "_process_batch", + "res_model": "spp.demo.data.generator", + "res_id": generator1.id, + "state": "pending", + } + ) + + # Trigger compute + generator1._compute_queue_job_ids() + generator2._compute_queue_job_ids() + + # Assert generator1 has 2 jobs + self.assertEqual(len(generator1.queue_job_ids), 2) + self.assertIn(job1, generator1.queue_job_ids) + self.assertIn(job2, generator1.queue_job_ids) + + # Assert generator2 has no jobs + self.assertEqual(len(generator2.queue_job_ids), 0) + + def test_29_compute_queue_job_count(self): + """Test queue job count computation""" + generator = self.env["spp.demo.data.generator"].create( + { + "name": "Generator for Count Test", + "locale_origin": self.test_country.id, + } + ) + + # Initially no jobs + generator._compute_queue_job_ids() + generator._compute_queue_job_count() + self.assertEqual(generator.queue_job_count, 0) + + # Create 3 jobs + for i in range(3): + self.env["queue.job"].create( + { + "name": f"Job {i+1}", + "model_name": "spp.demo.data.generator", + "method_name": "_process_batch", + "res_model": "spp.demo.data.generator", + "res_id": generator.id, + "state": "done", + } + ) + + # Recompute + generator._compute_queue_job_ids() + generator._compute_queue_job_count() + self.assertEqual(generator.queue_job_count, 3) + + def test_30_has_ongoing_jobs_no_jobs(self): + """Test has_ongoing_jobs when there are no jobs""" + # Clean up any existing jobs + self.env["queue.job"].search([("res_model", "=", "spp.demo.data.generator")]).unlink() + + generator = self.env["spp.demo.data.generator"].create( + { + "name": "Generator No Jobs", + "locale_origin": self.test_country.id, + } + ) + + generator._compute_ongoing_jobs_info() + self.assertFalse(generator.has_ongoing_jobs) + self.assertFalse(generator.ongoing_job_generator_id) + + def test_31_has_ongoing_jobs_with_pending_job(self): + """Test has_ongoing_jobs detects pending jobs""" + # Clean up any existing jobs + self.env["queue.job"].search([("res_model", "=", "spp.demo.data.generator")]).unlink() + + generator = self.env["spp.demo.data.generator"].create( + { + "name": "Generator with Pending Job", + "locale_origin": self.test_country.id, + } + ) + + # Create a pending job + self.env["queue.job"].create( + { + "name": "Pending Job", + "model_name": "spp.demo.data.generator", + "method_name": "_process_batch", + "res_model": "spp.demo.data.generator", + "res_id": generator.id, + "state": "pending", + } + ) + + generator._compute_ongoing_jobs_info() + self.assertTrue(generator.has_ongoing_jobs) + self.assertEqual(generator.ongoing_job_generator_id, generator) + + def test_32_has_ongoing_jobs_with_enqueued_job(self): + """Test has_ongoing_jobs detects enqueued jobs""" + # Clean up any existing jobs + self.env["queue.job"].search([("res_model", "=", "spp.demo.data.generator")]).unlink() + + generator = self.env["spp.demo.data.generator"].create( + { + "name": "Generator with Enqueued Job", + "locale_origin": self.test_country.id, + } + ) + + # Create an enqueued job + self.env["queue.job"].create( + { + "name": "Enqueued Job", + "model_name": "spp.demo.data.generator", + "method_name": "_process_batch", + "res_model": "spp.demo.data.generator", + "res_id": generator.id, + "state": "enqueued", + } + ) + + generator._compute_ongoing_jobs_info() + self.assertTrue(generator.has_ongoing_jobs) + self.assertEqual(generator.ongoing_job_generator_id, generator) + + def test_33_has_ongoing_jobs_with_started_job(self): + """Test has_ongoing_jobs detects started jobs""" + # Clean up any existing jobs + self.env["queue.job"].search([("res_model", "=", "spp.demo.data.generator")]).unlink() + + generator = self.env["spp.demo.data.generator"].create( + { + "name": "Generator with Started Job", + "locale_origin": self.test_country.id, + } + ) + + # Create a started job + self.env["queue.job"].create( + { + "name": "Started Job", + "model_name": "spp.demo.data.generator", + "method_name": "_process_batch", + "res_model": "spp.demo.data.generator", + "res_id": generator.id, + "state": "started", + } + ) + + generator._compute_ongoing_jobs_info() + self.assertTrue(generator.has_ongoing_jobs) + self.assertEqual(generator.ongoing_job_generator_id, generator) + + def test_34_has_ongoing_jobs_with_done_job(self): + """Test has_ongoing_jobs with completed jobs""" + # Clean up any existing jobs + self.env["queue.job"].search([("res_model", "=", "spp.demo.data.generator")]).unlink() + + generator = self.env["spp.demo.data.generator"].create( + { + "name": "Generator with Done Job", + "locale_origin": self.test_country.id, + } + ) + + # Create only done jobs + self.env["queue.job"].create( + { + "name": "Done Job", + "model_name": "spp.demo.data.generator", + "method_name": "_process_batch", + "res_model": "spp.demo.data.generator", + "res_id": generator.id, + "state": "done", + } + ) + + generator._compute_ongoing_jobs_info() + self.assertFalse(generator.has_ongoing_jobs) + self.assertFalse(generator.ongoing_job_generator_id) + + def test_35_has_ongoing_jobs_cross_record(self): + """Test has_ongoing_jobs affects all records when any generator has ongoing jobs""" + # Clean up any existing jobs + self.env["queue.job"].search([("res_model", "=", "spp.demo.data.generator")]).unlink() + + generator1 = self.env["spp.demo.data.generator"].create( + { + "name": "Generator 1", + "locale_origin": self.test_country.id, + } + ) + generator2 = self.env["spp.demo.data.generator"].create( + { + "name": "Generator 2", + "locale_origin": self.test_country.id, + } + ) + + # Create a pending job for generator1 only + self.env["queue.job"].create( + { + "name": "Pending Job for Generator 1", + "model_name": "spp.demo.data.generator", + "method_name": "_process_batch", + "res_model": "spp.demo.data.generator", + "res_id": generator1.id, + "state": "pending", + } + ) + + # Trigger compute on both + generator1._compute_ongoing_jobs_info() + generator2._compute_ongoing_jobs_info() + + # Both should show has_ongoing_jobs = True + self.assertTrue(generator1.has_ongoing_jobs) + self.assertTrue(generator2.has_ongoing_jobs) + + # Both should point to generator1 as the one with ongoing jobs + self.assertEqual(generator1.ongoing_job_generator_id, generator1) + self.assertEqual(generator2.ongoing_job_generator_id, generator1) + + def test_36_has_ongoing_jobs_mixed_states(self): + """Test has_ongoing_jobs with mixed job states""" + # Clean up any existing jobs + self.env["queue.job"].search([("res_model", "=", "spp.demo.data.generator")]).unlink() + + generator = self.env["spp.demo.data.generator"].create( + { + "name": "Generator Mixed States", + "locale_origin": self.test_country.id, + } + ) + + # Create jobs with different states + self.env["queue.job"].create( + { + "name": "Done Job", + "model_name": "spp.demo.data.generator", + "method_name": "_process_batch", + "res_model": "spp.demo.data.generator", + "res_id": generator.id, + "state": "done", + } + ) + self.env["queue.job"].create( + { + "name": "Failed Job", + "model_name": "spp.demo.data.generator", + "method_name": "_process_batch", + "res_model": "spp.demo.data.generator", + "res_id": generator.id, + "state": "failed", + } + ) + self.env["queue.job"].create( + { + "name": "Pending Job", + "model_name": "spp.demo.data.generator", + "method_name": "_process_batch", + "res_model": "spp.demo.data.generator", + "res_id": generator.id, + "state": "pending", + } + ) + + generator._compute_ongoing_jobs_info() + + # Should be True because there's one pending job + self.assertTrue(generator.has_ongoing_jobs) + self.assertEqual(generator.ongoing_job_generator_id, generator) + + def test_37_queue_job_isolation_from_other_models(self): + """Test that jobs from other models don't affect demo generator""" + # Clean up any existing jobs + self.env["queue.job"].search([("res_model", "=", "spp.demo.data.generator")]).unlink() + + generator = self.env["spp.demo.data.generator"].create( + { + "name": "Generator Isolation Test", + "locale_origin": self.test_country.id, + } + ) + + # Create a pending job for a different model + self.env["queue.job"].create( + { + "name": "Job for different model", + "model_name": "res.partner", + "method_name": "test_method", + "res_model": "res.partner", + "res_id": 1, + "state": "pending", + } + ) + + generator._compute_ongoing_jobs_info() + generator._compute_queue_job_ids() + + # Should not be affected by jobs from other models + self.assertFalse(generator.has_ongoing_jobs) + self.assertFalse(generator.ongoing_job_generator_id) + self.assertEqual(len(generator.queue_job_ids), 0) From 88502b60517202014416d25c8dc743d13527b291 Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Thu, 20 Nov 2025 14:45:21 +0800 Subject: [PATCH 12/13] [FIX] queue job test creation with proper sentinel context --- spp_base_common/CHANGELOG.md | 7 + .../tests/test_queue_job_tracking.py | 159 ++++-------------- spp_demo_common/CHANGELOG.md | 7 + .../tests/test_demo_data_generator.py | 151 ++++------------- 4 files changed, 76 insertions(+), 248 deletions(-) diff --git a/spp_base_common/CHANGELOG.md b/spp_base_common/CHANGELOG.md index c61f00a90..b8aeebe1f 100644 --- a/spp_base_common/CHANGELOG.md +++ b/spp_base_common/CHANGELOG.md @@ -2,6 +2,13 @@ ## 2025-11-20 +### 2025-11-20 16:30:00 - [FIX] queue job test creation with proper sentinel context + +- Fixed test job creation to use `_job_edit_sentinel` context for protected fields +- Added `_create_test_job` helper method to simplify job creation in tests +- Updated all test methods to use helper instead of direct create() calls +- Tests now properly respect queue.job model's protected fields constraint + ### 2025-11-20 16:00:00 - [ADD] comprehensive tests for queue job tracking - Added `test_queue_job_tracking.py` with 10 comprehensive test cases diff --git a/spp_base_common/tests/test_queue_job_tracking.py b/spp_base_common/tests/test_queue_job_tracking.py index 265939866..b17f02bef 100644 --- a/spp_base_common/tests/test_queue_job_tracking.py +++ b/spp_base_common/tests/test_queue_job_tracking.py @@ -1,5 +1,7 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. +import uuid + from odoo.tests.common import TransactionCase @@ -10,6 +12,9 @@ def setUpClass(cls): cls.queue_job_model = cls.env["queue.job"] cls.area_import_model = cls.env["spp.area.import"] + # Get the EDIT_SENTINEL for protected fields + cls.job_edit_sentinel = cls.queue_job_model.EDIT_SENTINEL + # Create test area import records cls.area_import_1 = cls.area_import_model.create( { @@ -22,56 +27,35 @@ def setUpClass(cls): } ) - def test_01_queue_job_res_fields(self): - """Test that queue.job model has res_id and res_model fields""" - job = self.queue_job_model.create( + def _create_test_job(self, name, res_model, res_id, state="done", method_name="test_method"): + """Helper method to create test queue jobs with proper sentinel context""" + return self.queue_job_model.with_context(_job_edit_sentinel=self.job_edit_sentinel).create( { - "name": "Test Job", - "model_name": "spp.area.import", - "method_name": "test_method", - "res_model": "spp.area.import", - "res_id": self.area_import_1.id, + "uuid": str(uuid.uuid4()), + "name": name, + "model_name": res_model, + "method_name": method_name, + "res_model": res_model, + "res_id": res_id, + "state": state, } ) + def test_01_queue_job_res_fields(self): + """Test that queue.job model has res_id and res_model fields""" + job = self._create_test_job("Test Job", "spp.area.import", self.area_import_1.id, state="done") + self.assertEqual(job.res_model, "spp.area.import") self.assertEqual(job.res_id, self.area_import_1.id) def test_02_compute_job_ids(self): """Test that area import correctly computes related jobs""" # Create jobs for area_import_1 - job1 = self.queue_job_model.create( - { - "name": "Job 1 for Import 1", - "model_name": "spp.area.import", - "method_name": "test_method", - "res_model": "spp.area.import", - "res_id": self.area_import_1.id, - "state": "done", - } - ) - job2 = self.queue_job_model.create( - { - "name": "Job 2 for Import 1", - "model_name": "spp.area.import", - "method_name": "test_method", - "res_model": "spp.area.import", - "res_id": self.area_import_1.id, - "state": "pending", - } - ) + job1 = self._create_test_job("Job 1 for Import 1", "spp.area.import", self.area_import_1.id, state="done") + job2 = self._create_test_job("Job 2 for Import 1", "spp.area.import", self.area_import_1.id, state="pending") # Create job for area_import_2 - job3 = self.queue_job_model.create( - { - "name": "Job 1 for Import 2", - "model_name": "spp.area.import", - "method_name": "test_method", - "res_model": "spp.area.import", - "res_id": self.area_import_2.id, - "state": "done", - } - ) + job3 = self._create_test_job("Job 1 for Import 2", "spp.area.import", self.area_import_2.id, state="done") # Trigger compute self.area_import_1._compute_job_ids() @@ -100,16 +84,7 @@ def test_03_has_ongoing_jobs_no_jobs(self): def test_04_has_ongoing_jobs_with_pending_job(self): """Test has_ongoing_jobs when there is a pending job""" # Create a pending job - self.queue_job_model.create( - { - "name": "Pending Job", - "model_name": "spp.area.import", - "method_name": "test_method", - "res_model": "spp.area.import", - "res_id": self.area_import_1.id, - "state": "pending", - } - ) + self._create_test_job("Pending Job", "spp.area.import", self.area_import_1.id, state="pending") # Trigger compute on both records self.area_import_1._compute_has_ongoing_jobs() @@ -125,16 +100,7 @@ def test_05_has_ongoing_jobs_with_enqueued_job(self): self.queue_job_model.search([("res_model", "=", "spp.area.import")]).unlink() # Create an enqueued job - self.queue_job_model.create( - { - "name": "Enqueued Job", - "model_name": "spp.area.import", - "method_name": "test_method", - "res_model": "spp.area.import", - "res_id": self.area_import_2.id, - "state": "enqueued", - } - ) + self._create_test_job("Enqueued Job", "spp.area.import", self.area_import_2.id, state="enqueued") # Trigger compute self.area_import_1._compute_has_ongoing_jobs() @@ -150,16 +116,7 @@ def test_06_has_ongoing_jobs_with_started_job(self): self.queue_job_model.search([("res_model", "=", "spp.area.import")]).unlink() # Create a started job - self.queue_job_model.create( - { - "name": "Started Job", - "model_name": "spp.area.import", - "method_name": "test_method", - "res_model": "spp.area.import", - "res_id": self.area_import_1.id, - "state": "started", - } - ) + self._create_test_job("Started Job", "spp.area.import", self.area_import_1.id, state="started") # Trigger compute self.area_import_1._compute_has_ongoing_jobs() @@ -175,26 +132,8 @@ def test_07_has_ongoing_jobs_with_done_job(self): self.queue_job_model.search([("res_model", "=", "spp.area.import")]).unlink() # Create only done/failed jobs - self.queue_job_model.create( - { - "name": "Done Job", - "model_name": "spp.area.import", - "method_name": "test_method", - "res_model": "spp.area.import", - "res_id": self.area_import_1.id, - "state": "done", - } - ) - self.queue_job_model.create( - { - "name": "Failed Job", - "model_name": "spp.area.import", - "method_name": "test_method", - "res_model": "spp.area.import", - "res_id": self.area_import_2.id, - "state": "failed", - } - ) + self._create_test_job("Done Job", "spp.area.import", self.area_import_1.id, state="done") + self._create_test_job("Failed Job", "spp.area.import", self.area_import_2.id, state="failed") # Trigger compute self.area_import_1._compute_has_ongoing_jobs() @@ -210,26 +149,8 @@ def test_08_has_ongoing_jobs_mixed_states(self): self.queue_job_model.search([("res_model", "=", "spp.area.import")]).unlink() # Create jobs with different states - self.queue_job_model.create( - { - "name": "Done Job", - "model_name": "spp.area.import", - "method_name": "test_method", - "res_model": "spp.area.import", - "res_id": self.area_import_1.id, - "state": "done", - } - ) - self.queue_job_model.create( - { - "name": "Pending Job", - "model_name": "spp.area.import", - "method_name": "test_method", - "res_model": "spp.area.import", - "res_id": self.area_import_2.id, - "state": "pending", - } - ) + self._create_test_job("Done Job", "spp.area.import", self.area_import_1.id, state="done") + self._create_test_job("Pending Job", "spp.area.import", self.area_import_2.id, state="pending") # Trigger compute self.area_import_1._compute_has_ongoing_jobs() @@ -242,16 +163,7 @@ def test_08_has_ongoing_jobs_mixed_states(self): def test_09_job_ids_empty_for_different_model(self): """Test that job_ids is empty when jobs belong to different model""" # Create a job with different res_model - self.queue_job_model.create( - { - "name": "Job for different model", - "model_name": "res.partner", - "method_name": "test_method", - "res_model": "res.partner", - "res_id": 1, - "state": "done", - } - ) + self._create_test_job("Job for different model", "res.partner", 1, state="done") # Trigger compute self.area_import_1._compute_job_ids() @@ -265,16 +177,7 @@ def test_10_has_ongoing_jobs_different_model(self): self.queue_job_model.search([("res_model", "=", "spp.area.import")]).unlink() # Create a pending job for a different model - self.queue_job_model.create( - { - "name": "Job for different model", - "model_name": "res.partner", - "method_name": "test_method", - "res_model": "res.partner", - "res_id": 1, - "state": "pending", - } - ) + self._create_test_job("Job for different model", "res.partner", 1, state="pending") # Trigger compute self.area_import_1._compute_has_ongoing_jobs() diff --git a/spp_demo_common/CHANGELOG.md b/spp_demo_common/CHANGELOG.md index 70452839d..685f313cf 100644 --- a/spp_demo_common/CHANGELOG.md +++ b/spp_demo_common/CHANGELOG.md @@ -2,6 +2,13 @@ ## 2025-11-20 +### 2025-11-20 16:30:00 - [FIX] queue job test creation with proper sentinel context + +- Fixed test job creation to use `_job_edit_sentinel` context for protected fields +- Added `_create_test_job` helper method to simplify job creation in tests +- Updated all test methods (test_28 through test_37) to use helper instead of direct create() calls +- Tests now properly respect queue.job model's protected fields constraint + ### 2025-11-20 16:15:00 - [ADD] comprehensive tests for queue job tracking in demo data generator - Added test_28: Tests `_compute_queue_job_ids` correctly filters jobs by generator record diff --git a/spp_demo_common/tests/test_demo_data_generator.py b/spp_demo_common/tests/test_demo_data_generator.py index 3afef0cbe..3cc8657d3 100644 --- a/spp_demo_common/tests/test_demo_data_generator.py +++ b/spp_demo_common/tests/test_demo_data_generator.py @@ -1,6 +1,7 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. import datetime import logging +import uuid from dateutil.relativedelta import relativedelta @@ -22,6 +23,10 @@ def setUpClass(cls): ) ) + # Get the EDIT_SENTINEL for protected fields in queue.job + cls.queue_job_model = cls.env["queue.job"] + cls.job_edit_sentinel = cls.queue_job_model.EDIT_SENTINEL + # Create test country with faker locale cls.test_country = cls.env["res.country"].create( { @@ -625,6 +630,20 @@ def test_27_edge_case_regex_generation_complex(self): self.assertIsNotNone(result2) self.assertEqual(len(result2), 5) + def _create_test_job(self, name, res_model, res_id, state="done", method_name="_process_batch"): + """Helper method to create test queue jobs with proper sentinel context""" + return self.queue_job_model.with_context(_job_edit_sentinel=self.job_edit_sentinel).create( + { + "uuid": str(uuid.uuid4()), + "name": name, + "model_name": res_model, + "method_name": method_name, + "res_model": res_model, + "res_id": res_id, + "state": state, + } + ) + def test_28_compute_queue_job_ids(self): """Test that generator correctly computes related queue jobs""" generator1 = self.env["spp.demo.data.generator"].create( @@ -641,26 +660,8 @@ def test_28_compute_queue_job_ids(self): ) # Create jobs for generator1 - job1 = self.env["queue.job"].create( - { - "name": "Job 1 for Generator 1", - "model_name": "spp.demo.data.generator", - "method_name": "_process_batch", - "res_model": "spp.demo.data.generator", - "res_id": generator1.id, - "state": "done", - } - ) - job2 = self.env["queue.job"].create( - { - "name": "Job 2 for Generator 1", - "model_name": "spp.demo.data.generator", - "method_name": "_process_batch", - "res_model": "spp.demo.data.generator", - "res_id": generator1.id, - "state": "pending", - } - ) + job1 = self._create_test_job("Job 1 for Generator 1", "spp.demo.data.generator", generator1.id, state="done") + job2 = self._create_test_job("Job 2 for Generator 1", "spp.demo.data.generator", generator1.id, state="pending") # Trigger compute generator1._compute_queue_job_ids() @@ -690,16 +691,7 @@ def test_29_compute_queue_job_count(self): # Create 3 jobs for i in range(3): - self.env["queue.job"].create( - { - "name": f"Job {i+1}", - "model_name": "spp.demo.data.generator", - "method_name": "_process_batch", - "res_model": "spp.demo.data.generator", - "res_id": generator.id, - "state": "done", - } - ) + self._create_test_job(f"Job {i+1}", "spp.demo.data.generator", generator.id, state="done") # Recompute generator._compute_queue_job_ids() @@ -735,16 +727,7 @@ def test_31_has_ongoing_jobs_with_pending_job(self): ) # Create a pending job - self.env["queue.job"].create( - { - "name": "Pending Job", - "model_name": "spp.demo.data.generator", - "method_name": "_process_batch", - "res_model": "spp.demo.data.generator", - "res_id": generator.id, - "state": "pending", - } - ) + self._create_test_job("Pending Job", "spp.demo.data.generator", generator.id, state="pending") generator._compute_ongoing_jobs_info() self.assertTrue(generator.has_ongoing_jobs) @@ -763,16 +746,7 @@ def test_32_has_ongoing_jobs_with_enqueued_job(self): ) # Create an enqueued job - self.env["queue.job"].create( - { - "name": "Enqueued Job", - "model_name": "spp.demo.data.generator", - "method_name": "_process_batch", - "res_model": "spp.demo.data.generator", - "res_id": generator.id, - "state": "enqueued", - } - ) + self._create_test_job("Enqueued Job", "spp.demo.data.generator", generator.id, state="enqueued") generator._compute_ongoing_jobs_info() self.assertTrue(generator.has_ongoing_jobs) @@ -791,16 +765,7 @@ def test_33_has_ongoing_jobs_with_started_job(self): ) # Create a started job - self.env["queue.job"].create( - { - "name": "Started Job", - "model_name": "spp.demo.data.generator", - "method_name": "_process_batch", - "res_model": "spp.demo.data.generator", - "res_id": generator.id, - "state": "started", - } - ) + self._create_test_job("Started Job", "spp.demo.data.generator", generator.id, state="started") generator._compute_ongoing_jobs_info() self.assertTrue(generator.has_ongoing_jobs) @@ -819,16 +784,7 @@ def test_34_has_ongoing_jobs_with_done_job(self): ) # Create only done jobs - self.env["queue.job"].create( - { - "name": "Done Job", - "model_name": "spp.demo.data.generator", - "method_name": "_process_batch", - "res_model": "spp.demo.data.generator", - "res_id": generator.id, - "state": "done", - } - ) + self._create_test_job("Done Job", "spp.demo.data.generator", generator.id, state="done") generator._compute_ongoing_jobs_info() self.assertFalse(generator.has_ongoing_jobs) @@ -853,16 +809,7 @@ def test_35_has_ongoing_jobs_cross_record(self): ) # Create a pending job for generator1 only - self.env["queue.job"].create( - { - "name": "Pending Job for Generator 1", - "model_name": "spp.demo.data.generator", - "method_name": "_process_batch", - "res_model": "spp.demo.data.generator", - "res_id": generator1.id, - "state": "pending", - } - ) + self._create_test_job("Pending Job for Generator 1", "spp.demo.data.generator", generator1.id, state="pending") # Trigger compute on both generator1._compute_ongoing_jobs_info() @@ -889,36 +836,9 @@ def test_36_has_ongoing_jobs_mixed_states(self): ) # Create jobs with different states - self.env["queue.job"].create( - { - "name": "Done Job", - "model_name": "spp.demo.data.generator", - "method_name": "_process_batch", - "res_model": "spp.demo.data.generator", - "res_id": generator.id, - "state": "done", - } - ) - self.env["queue.job"].create( - { - "name": "Failed Job", - "model_name": "spp.demo.data.generator", - "method_name": "_process_batch", - "res_model": "spp.demo.data.generator", - "res_id": generator.id, - "state": "failed", - } - ) - self.env["queue.job"].create( - { - "name": "Pending Job", - "model_name": "spp.demo.data.generator", - "method_name": "_process_batch", - "res_model": "spp.demo.data.generator", - "res_id": generator.id, - "state": "pending", - } - ) + self._create_test_job("Done Job", "spp.demo.data.generator", generator.id, state="done") + self._create_test_job("Failed Job", "spp.demo.data.generator", generator.id, state="failed") + self._create_test_job("Pending Job", "spp.demo.data.generator", generator.id, state="pending") generator._compute_ongoing_jobs_info() @@ -939,16 +859,7 @@ def test_37_queue_job_isolation_from_other_models(self): ) # Create a pending job for a different model - self.env["queue.job"].create( - { - "name": "Job for different model", - "model_name": "res.partner", - "method_name": "test_method", - "res_model": "res.partner", - "res_id": 1, - "state": "pending", - } - ) + self._create_test_job("Job for different model", "res.partner", 1, state="pending", method_name="test_method") generator._compute_ongoing_jobs_info() generator._compute_queue_job_ids() From ace528c9d48949da48938a3b811a0d7ed7bf1911 Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Thu, 20 Nov 2025 15:00:55 +0800 Subject: [PATCH 13/13] [IMP] set spp_programs as application module --- spp_programs/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spp_programs/__manifest__.py b/spp_programs/__manifest__.py index 2cb6c5037..716a19116 100644 --- a/spp_programs/__manifest__.py +++ b/spp_programs/__manifest__.py @@ -56,7 +56,7 @@ }, "demo": [], "images": [], - "application": False, + "application": True, "installable": True, "auto_install": False, }