From 22e7010c01105d0ac92b3e8822cf1d43b255f208 Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Wed, 1 Apr 2026 19:52:42 +0200 Subject: [PATCH 1/4] [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 | 40 ++++++++++-- .../static/src/js/base_exception.js | 62 +++++++++++++++++++ base_exception/tests/test_base_exception.py | 41 ++++++++++++ 5 files changed, 151 insertions(+), 5 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..3c66a8c2cb7 100644 --- a/base_exception/models/base_exception_method.py +++ b/base_exception/models/base_exception_method.py @@ -7,11 +7,14 @@ import logging from collections import defaultdict -from odoo import _, api, models +from odoo import Command, _, 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,39 @@ 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": [Command.unlink(rule_id)]} + ) + for rule_id, records in rules_to_add.items(): + records.with_env(new_env).write( + {"exception_ids": [Command.link(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): + main_records = self._get_main_records() + all_ignore_exception = all( + main_records.filtered("exception_ids").mapped("ignore_exception") + ) + any_blocking_exception = any( + rule.is_blocking for rule in main_records.exception_ids + ) + return not all_ignore_exception or any_blocking_exception + @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; + } + }, +}); 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 1f9b009d5942f2101c184110db07738fafacb964 Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Mon, 6 Apr 2026 17:55:56 +0200 Subject: [PATCH 2/4] [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 d59669bccea35608ce08a230453e0dc259b1ee5c Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Wed, 8 Apr 2026 19:08:14 +0200 Subject: [PATCH 3/4] [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/__manifest__.py | 2 +- base_exception/models/base_exception.py | 10 +-- .../models/base_exception_method.py | 11 ++- base_exception/readme/CONTRIBUTORS.md | 1 + .../static/src/js/base_exception.esm.js | 44 ++++++++++ .../static/src/js/base_exception.js | 80 ------------------- 6 files changed, 59 insertions(+), 89 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/__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", ], }, } 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/models/base_exception_method.py b/base_exception/models/base_exception_method.py index 3c66a8c2cb7..aede4e815a8 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 @@ -107,9 +108,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("Exceptions detected") + 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): main_records = self._get_main_records() all_ignore_exception = all( 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..3ae4c083176 --- /dev/null +++ b/base_exception/static/src/js/base_exception.esm.js @@ -0,0 +1,44 @@ +import {registry} from "@web/core/registry"; + +/* 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; + 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); +} + +function baseExceptionErrorHandler(env, uncaughtError, originalError) { + const controller = env.services.action.currentController; + if ( + originalError.exceptionName === + "odoo.addons.base_exception.exceptions.BaseExceptionError" + ) { + 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", + }); + } 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}); 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 5df7af7ef23b71253ed80f120dcd5b98496437f4 Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Thu, 9 Apr 2026 18:21:47 +0200 Subject: [PATCH 4/4] [TEST] base_exception: Test exception rollbacking Make test decorator available for import in other modules Avoid using new env for unrelated tests --- .../models/base_exception_method.py | 10 ++- base_exception/tests/common.py | 40 ++++++++++ base_exception/tests/purchase_test.py | 5 ++ base_exception/tests/test_base_exception.py | 75 +++++++++++++------ 4 files changed, 105 insertions(+), 25 deletions(-) create mode 100644 base_exception/tests/common.py diff --git a/base_exception/models/base_exception_method.py b/base_exception/models/base_exception_method.py index aede4e815a8..9a34abf43be 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": [Command.unlink(rule_id)]} 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/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..cbc9bed14ac 100644 --- a/base_exception/tests/test_base_exception.py +++ b/base_exception/tests/test_base_exception.py @@ -2,26 +2,28 @@ # 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 import SUPERUSER_ID +from odoo.api import Environment from odoo.exceptions import UserError, ValidationError 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): def setUp(self): # FakeModelLoader must be used in setUp, not setUpClass super().setUp() - + self.env = 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 @@ -56,24 +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 - - @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() @@ -187,3 +171,46 @@ 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 + mock_base_exception_method_env(self, env=new_env), + self.assertRaises(BaseExceptionError), + ): + self.po.button_detect_and_confirm() + # 1. Entering assertRaises will create a first savepoint using self.env.cr. + # 2. 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. + # 3. As the with block of assertRaises is exited a rollback to the first + # savepoint will be triggered, what invalidates the second savepoint. + # + # 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 + # Ensure write from base.exception.method.detect_exceptions was called + # with the new env that must be committed as the main env is the one to + # be rollbacked. + 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")