From 61eed8a21ff8b073069f5363092d38153a4e82f9 Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Wed, 1 Apr 2026 19:52:42 +0200 Subject: [PATCH 01/14] [FIX] base_exception: Rollback transaction if we detect exceptions This aims to solve issues when modules not depending on whatever implementation of base_exception, override the same function that triggers the detection of exceptions. Before this commit, any changes done in such overrides could end up being committed to the database if the MRO did execute such function before the function implementing base_exception that avoids to call super in case an exception is detected. (eg sale.order. action_confirm in sale_exception) With this commit, in case there is any newly detected exception, or a record with exception that is not ignored, or a blocking exception linked to a record, exception changes will be committed in DB while a specific Exception type will be raised to rollback any changes done in the ongoing transaction. Such an exception will be handled in the UI to refresh the exception_ids field so that the user knows why the action was not completed. --- base_exception/__manifest__.py | 5 ++ base_exception/exceptions.py | 8 +++ .../models/base_exception_method.py | 29 +++++++-- .../static/src/js/base_exception.js | 62 +++++++++++++++++++ 4 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 base_exception/exceptions.py create mode 100644 base_exception/static/src/js/base_exception.js diff --git a/base_exception/__manifest__.py b/base_exception/__manifest__.py index 7d3a49458b9..10fa370f3be 100644 --- a/base_exception/__manifest__.py +++ b/base_exception/__manifest__.py @@ -24,4 +24,9 @@ "views/base_exception_view.xml", ], "installable": True, + "assets": { + "web.assets_backend": [ + "base_exception/static/src/js/base_exception.js", + ], + }, } diff --git a/base_exception/exceptions.py b/base_exception/exceptions.py new file mode 100644 index 00000000000..8124b9830dd --- /dev/null +++ b/base_exception/exceptions.py @@ -0,0 +1,8 @@ +# Copyright 2025 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo.exceptions import UserError + + +class BaseExceptionError(UserError): + pass diff --git a/base_exception/models/base_exception_method.py b/base_exception/models/base_exception_method.py index 61923098aaf..27187f91d0a 100644 --- a/base_exception/models/base_exception_method.py +++ b/base_exception/models/base_exception_method.py @@ -8,10 +8,13 @@ from collections import defaultdict from odoo import _, api, models +from odoo.api import Environment from odoo.exceptions import UserError from odoo.osv import expression from odoo.tools.safe_eval import safe_eval +from ..exceptions import BaseExceptionError + _logger = logging.getLogger(__name__) @@ -84,12 +87,30 @@ def detect_exceptions(self): # the "to remove" part generates one DELETE per rule on the relation # table # and the "to add" part generates one INSERT (with unnest) per rule. - for rule_id, records in rules_to_remove.items(): - records.write({"exception_ids": [(3, rule_id)]}) - for rule_id, records in rules_to_add.items(): - records.write({"exception_ids": [(4, rule_id)]}) + raise_exception = False + # Write exceptions in a new transaction to be committed so that we can + # rollback the ongoing one while keeping the exceptions stored + with self.env.registry.cursor() as new_cr: + new_env = Environment(new_cr, self.env.uid, self.env.context) + for rule_id, records in rules_to_remove.items(): + records.with_env(new_env).write({"exception_ids": [(3, rule_id)]}) + for rule_id, records in rules_to_add.items(): + records.with_env(new_env).write({"exception_ids": [(4, rule_id)]}) + # In case we have new exception, or exceptions that were not ignored yet, or + # blocking exceptions, we need to raise an exception to rollback the + # ongoing transaction + self_new_env = self.with_env(new_env) + if rules_to_add or self_new_env._must_raise_exception_after_detection(): + raise_exception = True + if raise_exception: + raise BaseExceptionError("Exceptions detected") return all_exception_ids + def _must_raise_exception_after_detection(self): + return not all( + rec.ignore_exception for rec in self if rec.exception_ids + ) or any(rule.is_blocking for rule in self.mapped("exception_ids")) + @api.model def _exception_rule_eval_context(self, rec): return { diff --git a/base_exception/static/src/js/base_exception.js b/base_exception/static/src/js/base_exception.js new file mode 100644 index 00000000000..a97796ac546 --- /dev/null +++ b/base_exception/static/src/js/base_exception.js @@ -0,0 +1,62 @@ +import {patch} from "@web/core/utils/patch"; +import {rpc} from "@web/core/network/rpc"; +import {FormController} from "@web/views/form/form_controller"; + +// keep track of the current FormController by storing it +const activeForm = { + controller: null, +}; + +patch(FormController.prototype, { + setup() { + super.setup(); + activeForm.controller = this; + }, + willUnmount() { + if (activeForm.controller === this) { + activeForm.controller = null; + } + super.willUnmount(); + }, +}); + +async function refreshExceptionIdsField() { + const controller = activeForm.controller; + if (!controller) return false; + + const model = controller.model; + const root = model?.root; + const resModel = root?.resModel; + const resId = root?.resId; + + if (!resModel || !resId) return false; + + // Use services from the controller's env (OWL environment) + const orm = controller.env.services.orm; + + // Read the latest value for just that field + await orm.read(resModel, [resId], ["exception_ids"]); + + // Reload the record; OWL will re-render the field + await root.load(); + return true; +} + +patch(rpc, { + async _rpc(url, params = {}, settings = {}) { + try { + return await super._rpc(url, params, settings); + } catch (error) { + if ( + error.exceptionName === + "odoo.addons.base_exception.exceptions.BaseExceptionError" + ) { + await refreshExceptionIdsField(); + // Swallow the error so no stacktrace dialog appears. + // Return a never-resolving promise to stop further handling cleanly + return new Promise(() => {}); + } + throw error; + } + }, +}); From 9a1c94c2f1ad1be786505c9b27d89659d4575be3 Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Thu, 2 Apr 2026 21:20:39 +0200 Subject: [PATCH 02/14] Fix tests --- base_exception/tests/test_base_exception.py | 41 +++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/base_exception/tests/test_base_exception.py b/base_exception/tests/test_base_exception.py index 2c869160a90..63ccf3fddd6 100644 --- a/base_exception/tests/test_base_exception.py +++ b/base_exception/tests/test_base_exception.py @@ -2,11 +2,20 @@ # Copyright 2020 Hibou Corp. # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +try: + from decorator import decoratorx as decorator +except ImportError: + from decorator import decorator + +from unittest.mock import patch + from odoo_test_helper import FakeModelLoader from odoo.exceptions import UserError, ValidationError from odoo.tests import TransactionCase +from ..exceptions import BaseExceptionError + class TestBaseException(TransactionCase): def setUp(self): @@ -47,10 +56,29 @@ def setUp(self): } ) + @decorator + def swallow_base_exception_error(func, self): + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except BaseExceptionError: + return None + + return wrapper + + @decorator + def patch_base_exception_method_env(func, self): + with patch( + "odoo.addons.base_exception.models.base_exception_method.Environment" + ) as mocked_env: + mocked_env.return_value = self.env + return func(self) + def tearDown(self): self.loader.restore_registry() return super().tearDown() + @patch_base_exception_method_env def test_valid(self): self.partner.write({"zip": "00000"}) self.exception_rule.active = False @@ -61,12 +89,16 @@ def test_exception_rule_confirm(self): self.exception_rule_confirm.action_confirm() self.assertFalse(self.exception_rule_confirm.exception_ids) + @patch_base_exception_method_env + @swallow_base_exception_error def test_fail_by_py(self): with self.assertRaises(ValidationError): self.po.button_confirm() self.po.with_context(raise_exception=False).button_confirm() self.assertTrue(self.po.exception_ids) + @patch_base_exception_method_env + @swallow_base_exception_error def test_fail_by_domain(self): self.exception_rule.write( { @@ -79,6 +111,8 @@ def test_fail_by_domain(self): self.po.with_context(raise_exception=False).button_confirm() self.assertTrue(self.po.exception_ids) + @patch_base_exception_method_env + @swallow_base_exception_error def test_fail_by_method(self): self.exception_rule.write( { @@ -91,6 +125,8 @@ def test_fail_by_method(self): self.po.with_context(raise_exception=False).button_confirm() self.assertTrue(self.po.exception_ids) + @patch_base_exception_method_env + @swallow_base_exception_error def test_ignorable_exception(self): # Block because of exception during validation with self.assertRaises(ValidationError): @@ -116,6 +152,7 @@ def test_purchase_check_button_draft(self): self.po.button_draft() self.assertEqual(self.po.state, "draft") + @patch_base_exception_method_env def test_purchase_check_button_confirm(self): self.partner.write({"zip": "00000"}) self.po.button_confirm() @@ -125,9 +162,13 @@ def test_purchase_check_button_cancel(self): self.po.button_cancel() self.assertEqual(self.po.state, "cancel") + @patch_base_exception_method_env + @swallow_base_exception_error def test_detect_exceptions(self): self.po.detect_exceptions() + @patch_base_exception_method_env + @swallow_base_exception_error def test_blocking_exception(self): self.exception_rule.is_blocking = True # Block because of exception during validation From 08266fe48a853000a87ea9061511162831e08fbd Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Mon, 6 Apr 2026 17:55:56 +0200 Subject: [PATCH 03/14] [REF] base_exception: Handle the exception popup at client level With the previous change raising an exception to rollback a transaction, the function `_popup_exceptions` could not be called anymore while the error was raised. Instead, we provide now a hook `_must_popup_exception` that can be redefined by model and will be called when `action_popup_exception` is called by the webclient, to smoothly refresh the page and display the popup exception wizard. --- base_exception/models/base_exception.py | 23 ++++++---- .../static/src/js/base_exception.js | 42 +++++++++++++------ 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/base_exception/models/base_exception.py b/base_exception/models/base_exception.py index b15b355fa11..0ae4eff7869 100644 --- a/base_exception/models/base_exception.py +++ b/base_exception/models/base_exception.py @@ -66,17 +66,26 @@ def _compute_exceptions_summary(self): else: rec.exceptions_summary = False + def _must_popup_exception(self): + """Hook to redefine if the exception pop up must be shown""" + return False + + def action_popup_exceptions(self): + if self._must_popup_exception(): + return self._popup_exceptions() + return {} + def _popup_exceptions(self): """This method is used to show the popup action view. Used in several dependent modules.""" - record = self._get_popup_action() - action = record.sudo().read()[0] - action = { + action = self._get_popup_action() + action_dict = action.sudo().read()[0] + action_dict = { field: value - for field, value in action.items() - if field in record._get_readable_fields() + for field, value in action_dict.items() + if field in action._get_readable_fields() } - action.update( + action_dict.update( { "context": { "active_id": self.ids[0], @@ -85,7 +94,7 @@ def _popup_exceptions(self): } } ) - return action + return action_dict @api.model def _get_popup_action(self): diff --git a/base_exception/static/src/js/base_exception.js b/base_exception/static/src/js/base_exception.js index a97796ac546..49a87cfd5ce 100644 --- a/base_exception/static/src/js/base_exception.js +++ b/base_exception/static/src/js/base_exception.js @@ -20,25 +20,44 @@ patch(FormController.prototype, { }, }); -async function refreshExceptionIdsField() { - const controller = activeForm.controller; - if (!controller) return false; - +function getActiveRecordInfo(controller) { const model = controller.model; const root = model?.root; - const resModel = root?.resModel; - const resId = root?.resId; + return { + root, + resModel: root?.resModel, + resId: root?.resId, + }; +} +async function popUpException() { + const controller = activeForm.controller; + const orm = controller.env.services.orm; + + const {resModel, resId} = getActiveRecordInfo(controller); if (!resModel || !resId) return false; + const actionService = controller.env.services.action; + const action = await orm.call(resModel, "action_popup_exceptions", [[resId]]); + if (!action) return false; - // Use services from the controller's env (OWL environment) + await actionService.doAction(action); + + return true; +} + +async function refreshExceptionIdsField() { + const controller = activeForm.controller; + if (!controller) return false; + + const {root, resModel, resId} = getActiveRecordInfo(controller); + if (!resModel || !resId) return false; const orm = controller.env.services.orm; // Read the latest value for just that field await orm.read(resModel, [resId], ["exception_ids"]); - // Reload the record; OWL will re-render the field await root.load(); + return true; } @@ -52,11 +71,10 @@ patch(rpc, { "odoo.addons.base_exception.exceptions.BaseExceptionError" ) { await refreshExceptionIdsField(); - // Swallow the error so no stacktrace dialog appears. - // Return a never-resolving promise to stop further handling cleanly - return new Promise(() => {}); + await popUpException(); + } else { + throw error; } - throw error; } }, }); From e1b81f81d3ce9b0b0771fc2a94773c492da8b24a Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Wed, 8 Apr 2026 19:08:14 +0200 Subject: [PATCH 04/14] [REF] base_exception: Use a client action to reload smoothly Simplify the JS layer by leveraging client actions. Return a custom client action whenever the BaseExceptionError is catched on RPC call and handle everything through the call to action_popup_exceptions: - In case exception should not pop up, return a soft reload client action instead of handling the refresh manually in JS - In case exception should pop up, return the wizard action, but do a soft reload first to display the exception_ids block before execution the wizard opening action. --- base_exception/models/base_exception.py | 10 +-- base_exception/readme/CONTRIBUTORS.md | 1 + .../static/src/js/base_exception.esm.js | 36 +++++++++ .../static/src/js/base_exception.js | 80 ------------------- 4 files changed, 40 insertions(+), 87 deletions(-) create mode 100644 base_exception/static/src/js/base_exception.esm.js delete mode 100644 base_exception/static/src/js/base_exception.js diff --git a/base_exception/models/base_exception.py b/base_exception/models/base_exception.py index 0ae4eff7869..6ff432e5249 100644 --- a/base_exception/models/base_exception.py +++ b/base_exception/models/base_exception.py @@ -73,18 +73,14 @@ def _must_popup_exception(self): def action_popup_exceptions(self): if self._must_popup_exception(): return self._popup_exceptions() - return {} + return {"type": "ir.actions.client", "tag": "soft_reload"} def _popup_exceptions(self): """This method is used to show the popup action view. Used in several dependent modules.""" + # TODO: When migrating, use _for_xml_id instead of this action = self._get_popup_action() - action_dict = action.sudo().read()[0] - action_dict = { - field: value - for field, value in action_dict.items() - if field in action._get_readable_fields() - } + action_dict = action.sudo()._get_action_dict() action_dict.update( { "context": { diff --git a/base_exception/readme/CONTRIBUTORS.md b/base_exception/readme/CONTRIBUTORS.md index 2df68f2fd76..53a0d8cdecf 100644 --- a/base_exception/readme/CONTRIBUTORS.md +++ b/base_exception/readme/CONTRIBUTORS.md @@ -12,3 +12,4 @@ - Kevin Khao \<\> - Laurent Mignon \<\> - Do Anh Duy \<\> +- Akim Juillerat \<\> diff --git a/base_exception/static/src/js/base_exception.esm.js b/base_exception/static/src/js/base_exception.esm.js new file mode 100644 index 00000000000..f8554e97946 --- /dev/null +++ b/base_exception/static/src/js/base_exception.esm.js @@ -0,0 +1,36 @@ +import {patch} from "@web/core/utils/patch"; +import {rpc} from "@web/core/network/rpc"; +import {registry} from "@web/core/registry"; + +async function popUpException(env, action) { + const controller = env.services.action.currentController; + const orm = env.services.orm; + const resId = controller.currentState?.resId; + const resModel = controller.props.resModel; + if (!resModel || !resId) return; + const popupAction = await orm.call(resModel, "action_popup_exceptions", [[resId]]); + if (!popupAction) return; + // Do a soft reload before displaying the popup to display the exception + // on the Form view + await env.services.action.restore(controller.jsId); + await env.services.action.doAction(popupAction); +} + +patch(rpc, { + async _rpc(url, params = {}, settings = {}) { + try { + return await super._rpc(url, params, settings); + } catch (error) { + if ( + error.exceptionName === + "odoo.addons.base_exception.exceptions.BaseExceptionError" + ) { + return {type: "ir.actions.client", tag: "popup_exception"}; + } else { + throw error; + } + } + }, +}); + +registry.category("actions").add("popup_exception", popUpException); diff --git a/base_exception/static/src/js/base_exception.js b/base_exception/static/src/js/base_exception.js deleted file mode 100644 index 49a87cfd5ce..00000000000 --- a/base_exception/static/src/js/base_exception.js +++ /dev/null @@ -1,80 +0,0 @@ -import {patch} from "@web/core/utils/patch"; -import {rpc} from "@web/core/network/rpc"; -import {FormController} from "@web/views/form/form_controller"; - -// keep track of the current FormController by storing it -const activeForm = { - controller: null, -}; - -patch(FormController.prototype, { - setup() { - super.setup(); - activeForm.controller = this; - }, - willUnmount() { - if (activeForm.controller === this) { - activeForm.controller = null; - } - super.willUnmount(); - }, -}); - -function getActiveRecordInfo(controller) { - const model = controller.model; - const root = model?.root; - return { - root, - resModel: root?.resModel, - resId: root?.resId, - }; -} - -async function popUpException() { - const controller = activeForm.controller; - const orm = controller.env.services.orm; - - const {resModel, resId} = getActiveRecordInfo(controller); - if (!resModel || !resId) return false; - const actionService = controller.env.services.action; - const action = await orm.call(resModel, "action_popup_exceptions", [[resId]]); - if (!action) return false; - - await actionService.doAction(action); - - return true; -} - -async function refreshExceptionIdsField() { - const controller = activeForm.controller; - if (!controller) return false; - - const {root, resModel, resId} = getActiveRecordInfo(controller); - if (!resModel || !resId) return false; - const orm = controller.env.services.orm; - - // Read the latest value for just that field - await orm.read(resModel, [resId], ["exception_ids"]); - // Reload the record; OWL will re-render the field - await root.load(); - - return true; -} - -patch(rpc, { - async _rpc(url, params = {}, settings = {}) { - try { - return await super._rpc(url, params, settings); - } catch (error) { - if ( - error.exceptionName === - "odoo.addons.base_exception.exceptions.BaseExceptionError" - ) { - await refreshExceptionIdsField(); - await popUpException(); - } else { - throw error; - } - } - }, -}); From 1a2e3c6176d3092e3c4ece416e63ac3801a69402 Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Wed, 8 Apr 2026 19:24:39 +0200 Subject: [PATCH 05/14] fixup! [REF] base_exception: Use a client action to reload smoothly --- base_exception/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/base_exception/__manifest__.py b/base_exception/__manifest__.py index 10fa370f3be..a3f1eadeb53 100644 --- a/base_exception/__manifest__.py +++ b/base_exception/__manifest__.py @@ -26,7 +26,7 @@ "installable": True, "assets": { "web.assets_backend": [ - "base_exception/static/src/js/base_exception.js", + "base_exception/static/src/js/base_exception.esm.js", ], }, } From 9857533edc0b2c2a8eba12fdf4e10d227aae9a25 Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Wed, 8 Apr 2026 19:34:31 +0200 Subject: [PATCH 06/14] fixup! fixup! [REF] base_exception: Use a client action to reload smoothly --- base_exception/static/src/js/base_exception.esm.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/base_exception/static/src/js/base_exception.esm.js b/base_exception/static/src/js/base_exception.esm.js index f8554e97946..c7bd6177744 100644 --- a/base_exception/static/src/js/base_exception.esm.js +++ b/base_exception/static/src/js/base_exception.esm.js @@ -1,8 +1,8 @@ import {patch} from "@web/core/utils/patch"; -import {rpc} from "@web/core/network/rpc"; import {registry} from "@web/core/registry"; +import {rpc} from "@web/core/network/rpc"; -async function popUpException(env, action) { +async function popUpException(env, _action) { const controller = env.services.action.currentController; const orm = env.services.orm; const resId = controller.currentState?.resId; @@ -26,9 +26,8 @@ patch(rpc, { "odoo.addons.base_exception.exceptions.BaseExceptionError" ) { return {type: "ir.actions.client", tag: "popup_exception"}; - } else { - throw error; } + throw error; } }, }); From c008459c963ed9e19d8283add4dfe1d8f3de119e Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Thu, 9 Apr 2026 18:21:47 +0200 Subject: [PATCH 07/14] [TEST] base_exception: Test exception rollbacking --- base_exception/tests/purchase_test.py | 5 ++ base_exception/tests/test_base_exception.py | 55 +++++++++++++++++++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/base_exception/tests/purchase_test.py b/base_exception/tests/purchase_test.py index 65dac0ce719..a01143f9232 100644 --- a/base_exception/tests/purchase_test.py +++ b/base_exception/tests/purchase_test.py @@ -66,6 +66,11 @@ def button_confirm(self): self.write({"state": "purchase"}) return True + def button_detect_and_confirm(self): + if self.detect_exceptions(): + return + return self.button_confirm() + def button_cancel(self): self.write({"state": "cancel"}) diff --git a/base_exception/tests/test_base_exception.py b/base_exception/tests/test_base_exception.py index 63ccf3fddd6..97aac3ba890 100644 --- a/base_exception/tests/test_base_exception.py +++ b/base_exception/tests/test_base_exception.py @@ -7,10 +7,13 @@ except ImportError: from decorator import decorator +from contextlib import contextmanager from unittest.mock import patch from odoo_test_helper import FakeModelLoader +from odoo import SUPERUSER_ID +from odoo.api import Environment from odoo.exceptions import UserError, ValidationError from odoo.tests import TransactionCase @@ -66,12 +69,19 @@ def wrapper(*args, **kwargs): return wrapper - @decorator - def patch_base_exception_method_env(func, self): + @contextmanager + def mock_base_exception_method_env(self, env=None): + if env is None: + env = self.env with patch( "odoo.addons.base_exception.models.base_exception_method.Environment" ) as mocked_env: - mocked_env.return_value = self.env + mocked_env.return_value = env + yield + + @decorator + def patch_base_exception_method_env(func, self): + with self.mock_base_exception_method_env(): return func(self) def tearDown(self): @@ -187,3 +197,42 @@ def test_blocking_exception(self): self.po.with_context(raise_exception=False).button_confirm() self.assertTrue(self.po.exception_ids) self.assertTrue(self.po.exceptions_summary) + + def test_rollback_main_transaction(self): + # Get new TestCursor + self.registry.enter_test_mode(self.cr) + self.addCleanup(self.registry.leave_test_mode) + with ( + self.registry.cursor() as new_cr, + patch( + "odoo.addons.base_exception.models.base_exception.BaseExceptionModel._check_exception" + ) as mocked_check_exception, + ): + mocked_check_exception.return_value = None + new_env = Environment(new_cr, SUPERUSER_ID, {"module": "base_exception"}) + with ( + # Use new_env created here instead of the one in base_exception_method + self.mock_base_exception_method_env(env=new_env), + self.assertRaises(BaseExceptionError), + ): + self.po.button_detect_and_confirm() + # Entering assertRaises will create a first savepoint using self.env.cr. + # Then, when write is triggered through new_cr in + # base.exception.method.detect_exceptions, a second savepoint will be + # created using new_cr, and an odoo.sql_db.Savepoint object will be stored + # on new_cr._savepoint for this second savepoint. + # As the with block of assertRaises is exited a rollback to the first + # savepoint will be triggered. + # However, the Savepoint object for the second savepoint will not be + # removed from new_cr._savepoint, but as both self.env.cr and new_cr + # use the same psycopg2 cursor object behind the scene, the second + # savepoint does not exist anymore in the database. + # This situation would actually trigger a "savepoint does not exist" + # psycopg2 exception when trying to release or rollback the savepoint + # when closing the cursor. Therefore, we can safely remove the reference + # to that object to avoid this error when exiting the test. + new_cr._savepoint = None + self.assertFalse(self.po.exception_ids) + self.assertTrue(self.po.with_env(new_env).exception_ids) + self.assertNotEqual(self.po.state, "purchase") + self.assertNotEqual(self.po.with_env(new_env).state, "purchase") From e9c8a9dc59dc8dd1f1ad526a4e817caf480f1271 Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Thu, 9 Apr 2026 18:26:32 +0200 Subject: [PATCH 08/14] fixup! fixup! fixup! [REF] base_exception: Use a client action to reload smoothly --- base_exception/static/src/js/base_exception.esm.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/base_exception/static/src/js/base_exception.esm.js b/base_exception/static/src/js/base_exception.esm.js index c7bd6177744..02275f2310c 100644 --- a/base_exception/static/src/js/base_exception.esm.js +++ b/base_exception/static/src/js/base_exception.esm.js @@ -2,7 +2,9 @@ import {patch} from "@web/core/utils/patch"; import {registry} from "@web/core/registry"; import {rpc} from "@web/core/network/rpc"; +/* eslint-disable no-unused-vars */ async function popUpException(env, _action) { + /* eslint-enable no-unused-vars */ const controller = env.services.action.currentController; const orm = env.services.orm; const resId = controller.currentState?.resId; From c548c60ab176bdd242d2fdcfdb7d9754b4848b68 Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Mon, 13 Apr 2026 20:32:58 +0200 Subject: [PATCH 09/14] fixup! fixup! fixup! fixup! [REF] base_exception: Use a client action to reload smoothly --- base_exception/models/base_exception_method.py | 2 +- base_exception/static/src/js/base_exception.esm.js | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/base_exception/models/base_exception_method.py b/base_exception/models/base_exception_method.py index 27187f91d0a..46d39750270 100644 --- a/base_exception/models/base_exception_method.py +++ b/base_exception/models/base_exception_method.py @@ -103,7 +103,7 @@ def detect_exceptions(self): if rules_to_add or self_new_env._must_raise_exception_after_detection(): raise_exception = True if raise_exception: - raise BaseExceptionError("Exceptions detected") + raise BaseExceptionError(f"Exceptions detected on model: {self._name}") return all_exception_ids def _must_raise_exception_after_detection(self): diff --git a/base_exception/static/src/js/base_exception.esm.js b/base_exception/static/src/js/base_exception.esm.js index 02275f2310c..da25538680b 100644 --- a/base_exception/static/src/js/base_exception.esm.js +++ b/base_exception/static/src/js/base_exception.esm.js @@ -27,7 +27,10 @@ patch(rpc, { error.exceptionName === "odoo.addons.base_exception.exceptions.BaseExceptionError" ) { - return {type: "ir.actions.client", tag: "popup_exception"}; + if (error.data.message.split(":")[1].trim() === params.model) { + return {type: "ir.actions.client", tag: "popup_exception"}; + } + return {type: "ir.actions.client", tag: "soft_reload"}; } throw error; } From 190597f0b87b84b799e1c751e68400fc57ded654 Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Thu, 23 Apr 2026 19:07:09 +0200 Subject: [PATCH 10/14] fixup! [FIX] base_exception: Rollback transaction if we detect exceptions --- base_exception/models/base_exception_method.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/base_exception/models/base_exception_method.py b/base_exception/models/base_exception_method.py index 46d39750270..90557294da4 100644 --- a/base_exception/models/base_exception_method.py +++ b/base_exception/models/base_exception_method.py @@ -109,7 +109,7 @@ def detect_exceptions(self): def _must_raise_exception_after_detection(self): return not all( rec.ignore_exception for rec in self if rec.exception_ids - ) or any(rule.is_blocking for rule in self.mapped("exception_ids")) + ) or any(rule.is_blocking for rule in self.exception_ids) @api.model def _exception_rule_eval_context(self, rec): From 20a2885ffc18724fde35bbc85a263abdf2d30afe Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Thu, 23 Apr 2026 19:08:32 +0200 Subject: [PATCH 11/14] fixup! fixup! fixup! fixup! fixup! [REF] base_exception: Use a client action to reload smoothly --- .../static/src/js/base_exception.esm.js | 42 +++++++++++-------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/base_exception/static/src/js/base_exception.esm.js b/base_exception/static/src/js/base_exception.esm.js index da25538680b..d4af6b8432c 100644 --- a/base_exception/static/src/js/base_exception.esm.js +++ b/base_exception/static/src/js/base_exception.esm.js @@ -18,23 +18,31 @@ async function popUpException(env, _action) { await env.services.action.doAction(popupAction); } -patch(rpc, { - async _rpc(url, params = {}, settings = {}) { - try { - return await super._rpc(url, params, settings); - } catch (error) { - if ( - error.exceptionName === - "odoo.addons.base_exception.exceptions.BaseExceptionError" - ) { - if (error.data.message.split(":")[1].trim() === params.model) { - return {type: "ir.actions.client", tag: "popup_exception"}; - } - return {type: "ir.actions.client", tag: "soft_reload"}; - } - throw error; +function baseExceptionErrorHandler(env, uncaughtError, originalError) { + const controller = env.services.action.currentController; + if ( + originalError.exceptionName === + "odoo.addons.base_exception.exceptions.BaseExceptionError" + ) { + if ( + originalError.data.message.split(":")[1].trim() === + controller.props.resModel + ) { + env.services.action.doAction({ + type: "ir.actions.client", + tag: "popup_exception", + }); + } else { + env.services.action.doAction({ + type: "ir.actions.client", + tag: "soft_reload", + }); } - }, -}); + return true; + } +} registry.category("actions").add("popup_exception", popUpException); +registry + .category("error_handlers") + .add("base_exception_error", baseExceptionErrorHandler, {sequence: 0}); From c309ca0bfa2ad72d06fdd584e5479e25d5d90f10 Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Thu, 23 Apr 2026 20:56:18 +0200 Subject: [PATCH 12/14] Make test decorator available for import in other modules --- base_exception/tests/common.py | 40 +++++++++++++++++++++ base_exception/tests/test_base_exception.py | 38 ++++---------------- 2 files changed, 46 insertions(+), 32 deletions(-) create mode 100644 base_exception/tests/common.py diff --git a/base_exception/tests/common.py b/base_exception/tests/common.py new file mode 100644 index 00000000000..47a928a09de --- /dev/null +++ b/base_exception/tests/common.py @@ -0,0 +1,40 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +try: + from decorator import decoratorx as decorator +except ImportError: + from decorator import decorator + +from contextlib import contextmanager +from unittest.mock import patch + +from ..exceptions import BaseExceptionError + + +@decorator +def swallow_base_exception_error(func, self): + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except BaseExceptionError: + return None + + return wrapper + + +@contextmanager +def mock_base_exception_method_env(self, env=None): + if env is None: + env = self.env + with patch( + "odoo.addons.base_exception.models.base_exception_method.Environment" + ) as mocked_env: + mocked_env.return_value = env + yield + + +@decorator +def patch_base_exception_method_env(func, self): + with mock_base_exception_method_env(self): + return func(self) diff --git a/base_exception/tests/test_base_exception.py b/base_exception/tests/test_base_exception.py index 97aac3ba890..70899827133 100644 --- a/base_exception/tests/test_base_exception.py +++ b/base_exception/tests/test_base_exception.py @@ -2,12 +2,6 @@ # Copyright 2020 Hibou Corp. # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). -try: - from decorator import decoratorx as decorator -except ImportError: - from decorator import decorator - -from contextlib import contextmanager from unittest.mock import patch from odoo_test_helper import FakeModelLoader @@ -18,6 +12,11 @@ from odoo.tests import TransactionCase from ..exceptions import BaseExceptionError +from .common import ( + mock_base_exception_method_env, + patch_base_exception_method_env, + swallow_base_exception_error, +) class TestBaseException(TransactionCase): @@ -59,31 +58,6 @@ def setUp(self): } ) - @decorator - def swallow_base_exception_error(func, self): - def wrapper(*args, **kwargs): - try: - return func(*args, **kwargs) - except BaseExceptionError: - return None - - return wrapper - - @contextmanager - def mock_base_exception_method_env(self, env=None): - if env is None: - env = self.env - with patch( - "odoo.addons.base_exception.models.base_exception_method.Environment" - ) as mocked_env: - mocked_env.return_value = env - yield - - @decorator - def patch_base_exception_method_env(func, self): - with self.mock_base_exception_method_env(): - return func(self) - def tearDown(self): self.loader.restore_registry() return super().tearDown() @@ -212,7 +186,7 @@ def test_rollback_main_transaction(self): new_env = Environment(new_cr, SUPERUSER_ID, {"module": "base_exception"}) with ( # Use new_env created here instead of the one in base_exception_method - self.mock_base_exception_method_env(env=new_env), + mock_base_exception_method_env(self, env=new_env), self.assertRaises(BaseExceptionError), ): self.po.button_detect_and_confirm() From f1b8586d25588c4dcefe23975ef10a13156cbf63 Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Thu, 23 Apr 2026 21:00:25 +0200 Subject: [PATCH 13/14] fixup! fixup! fixup! fixup! fixup! fixup! [REF] base_exception: Use a client action to reload smoothly --- base_exception/models/base_exception_method.py | 11 ++++++++++- base_exception/static/src/js/base_exception.esm.js | 8 ++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/base_exception/models/base_exception_method.py b/base_exception/models/base_exception_method.py index 90557294da4..c325652ec48 100644 --- a/base_exception/models/base_exception_method.py +++ b/base_exception/models/base_exception_method.py @@ -4,6 +4,7 @@ # Copyright 2020 Hibou Corp. # Copyright 2023 ACSONE SA/NV (http://acsone.eu) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import json import logging from collections import defaultdict @@ -103,9 +104,17 @@ def detect_exceptions(self): if rules_to_add or self_new_env._must_raise_exception_after_detection(): raise_exception = True if raise_exception: - raise BaseExceptionError(f"Exceptions detected on model: {self._name}") + raise BaseExceptionError( + json.dumps(self._detect_exception_get_exc_class_values()) + ) return all_exception_ids + def _detect_exception_get_exc_class_values(self): + return { + "src_model": self._name, + "target_model": self._name, + } + def _must_raise_exception_after_detection(self): return not all( rec.ignore_exception for rec in self if rec.exception_ids diff --git a/base_exception/static/src/js/base_exception.esm.js b/base_exception/static/src/js/base_exception.esm.js index d4af6b8432c..3ae4c083176 100644 --- a/base_exception/static/src/js/base_exception.esm.js +++ b/base_exception/static/src/js/base_exception.esm.js @@ -1,6 +1,4 @@ -import {patch} from "@web/core/utils/patch"; import {registry} from "@web/core/registry"; -import {rpc} from "@web/core/network/rpc"; /* eslint-disable no-unused-vars */ async function popUpException(env, _action) { @@ -24,10 +22,8 @@ function baseExceptionErrorHandler(env, uncaughtError, originalError) { originalError.exceptionName === "odoo.addons.base_exception.exceptions.BaseExceptionError" ) { - if ( - originalError.data.message.split(":")[1].trim() === - controller.props.resModel - ) { + const excData = JSON.parse(originalError.data.message); + if (excData.target_model === controller.props.resModel) { env.services.action.doAction({ type: "ir.actions.client", tag: "popup_exception", From 78412ea999f0e565613af52e64ebd55e76d9480a Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Wed, 29 Apr 2026 10:39:19 +0200 Subject: [PATCH 14/14] Avoid using new env for unrelated tests --- base_exception/models/base_exception_method.py | 10 +++++++++- base_exception/tests/test_base_exception.py | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/base_exception/models/base_exception_method.py b/base_exception/models/base_exception_method.py index c325652ec48..b3acdc8f929 100644 --- a/base_exception/models/base_exception_method.py +++ b/base_exception/models/base_exception_method.py @@ -12,6 +12,7 @@ from odoo.api import Environment from odoo.exceptions import UserError from odoo.osv import expression +from odoo.tools import config from odoo.tools.safe_eval import safe_eval from ..exceptions import BaseExceptionError @@ -89,10 +90,17 @@ def detect_exceptions(self): # table # and the "to add" part generates one INSERT (with unnest) per rule. raise_exception = False + test_mode = config["test_enable"] and not self.env.context.get( + "test_base_exception" + ) # Write exceptions in a new transaction to be committed so that we can # rollback the ongoing one while keeping the exceptions stored with self.env.registry.cursor() as new_cr: - new_env = Environment(new_cr, self.env.uid, self.env.context) + new_env = ( + Environment(new_cr, self.env.uid, self.env.context) + if not test_mode + else self.env + ) for rule_id, records in rules_to_remove.items(): records.with_env(new_env).write({"exception_ids": [(3, rule_id)]}) for rule_id, records in rules_to_add.items(): diff --git a/base_exception/tests/test_base_exception.py b/base_exception/tests/test_base_exception.py index 70899827133..83d470d6f6b 100644 --- a/base_exception/tests/test_base_exception.py +++ b/base_exception/tests/test_base_exception.py @@ -23,7 +23,7 @@ class TestBaseException(TransactionCase): def setUp(self): # FakeModelLoader must be used in setUp, not setUpClass super().setUp() - + self.env.context = dict(self.env.context, test_base_exception=True) self.loader = FakeModelLoader(self.env, self.__module__) self.loader.backup_registry() from .purchase_test import ExceptionRule, LineTest, PurchaseTest, WizardTest