From c4819469ba407a30aba4867306a1f0eaa991e84c Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Wed, 1 Apr 2026 19:52:42 +0200 Subject: [PATCH 1/8] [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 bfb1682204e..20a658fd03c 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 d5926468658..f046f23d9f6 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.fields import Domain 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 dac21d1e6a7..695db5c3a3a 100644 --- a/base_exception/tests/test_base_exception.py +++ b/base_exception/tests/test_base_exception.py @@ -2,10 +2,19 @@ # 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.exceptions import UserError, ValidationError from odoo.orm.model_classes import add_to_registry from odoo.tests import TransactionCase +from ..exceptions import BaseExceptionError + class TestBaseException(TransactionCase): @classmethod @@ -69,6 +78,25 @@ def setUpClass(cls): def restore_exception_rule(cls): cls.registry["exception.rule"]._base_classes__ = cls.originExceptionRuleClasses + @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) + + @patch_base_exception_method_env def test_valid(self): self.partner.write({"zip": "00000"}) self.exception_rule.active = False @@ -79,12 +107,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( { @@ -97,6 +129,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( { @@ -109,6 +143,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): @@ -134,6 +170,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() @@ -143,9 +180,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 0585624217db93ebe51ecf382c398fd7adda764e Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Mon, 6 Apr 2026 17:55:56 +0200 Subject: [PATCH 2/8] [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 0c485401688..c9bbd6d3a13 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 fd9116ef395ff4c6a331b7e23938a41ad896c2a2 Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Wed, 8 Apr 2026 19:08:14 +0200 Subject: [PATCH 3/8] [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/README.rst | 1 + base_exception/__manifest__.py | 2 +- base_exception/models/base_exception.py | 10 +-- .../models/base_exception_method.py | 11 ++- base_exception/readme/CONTRIBUTORS.md | 1 + base_exception/static/description/index.html | 1 + .../static/src/js/base_exception.esm.js | 44 ++++++++++ .../static/src/js/base_exception.js | 80 ------------------- 8 files changed, 61 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/README.rst b/base_exception/README.rst index 673390c682a..22cad868be4 100644 --- a/base_exception/README.rst +++ b/base_exception/README.rst @@ -91,6 +91,7 @@ Contributors - Kevin Khao - Laurent Mignon - Do Anh Duy +- Akim Juillerat Other credits ------------- diff --git a/base_exception/__manifest__.py b/base_exception/__manifest__.py index 20a658fd03c..4766770d94c 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 c9bbd6d3a13..1504214a2a7 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 f046f23d9f6..eb8499120de 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/description/index.html b/base_exception/static/description/index.html index 624e3395557..80b3b4e2eb8 100644 --- a/base_exception/static/description/index.html +++ b/base_exception/static/description/index.html @@ -438,6 +438,7 @@

Contributors

  • Kevin Khao <kevin.khao@akretion.com>
  • Laurent Mignon <laurent.mignon@acsone.eu>
  • Do Anh Duy <duyda@trobz.com>
  • +
  • Akim Juillerat <akim.juillerat@camptocamp.com>
  • 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 1e71842b950e5da5402cba58b20318930423e4ef Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Thu, 9 Apr 2026 18:21:47 +0200 Subject: [PATCH 4/8] [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 | 74 +++++++++++++------ 4 files changed, 105 insertions(+), 24 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 eb8499120de..2814872fe6b 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.fields import Domain +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 ae1ce7e66d6..72809c780f0 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 695db5c3a3a..a1c65845823 100644 --- a/base_exception/tests/test_base_exception.py +++ b/base_exception/tests/test_base_exception.py @@ -2,24 +2,27 @@ # 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 import SUPERUSER_ID +from odoo.api import Environment from odoo.exceptions import UserError, ValidationError from odoo.orm.model_classes import add_to_registry 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): @classmethod def setUpClass(cls): super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, test_base_exception=True)) cls.originExceptionRuleClasses = cls.registry["exception.rule"]._base_classes__ from . import purchase_test @@ -78,24 +81,6 @@ def setUpClass(cls): def restore_exception_rule(cls): cls.registry["exception.rule"]._base_classes__ = cls.originExceptionRuleClasses - @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) - @patch_base_exception_method_env def test_valid(self): self.partner.write({"zip": "00000"}) @@ -205,3 +190,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") From afd3d2f6515165f4c63a942076fd2b48be421673 Mon Sep 17 00:00:00 2001 From: Florian da Costa Date: Thu, 10 Sep 2026 23:57:25 +0200 Subject: [PATCH 5/8] base_exception: no second cursor when installing modules detect_exceptions() stores the exceptions in a separate transaction (a new cursor/connection) so that they are kept when the ongoing transaction is rolled back. When the registry is not ready yet (module installation or update), the ongoing transaction holds exclusive locks on the tables it has just modified (ALTER TABLE ...). The call to _must_raise_exception_after_detection() made inside that new transaction then reads the model records, and the query waits for those locks forever: the only thread able to release them is the one waiting for them. This happens as soon as demo data confirming a sale order is loaded during a database initialisation with sale_exception installed (e.g. the sale_stock demo data), making the whole database initialisation hang. Use the current cursor in that case, as already done when running tests. --- base_exception/models/base_exception_method.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/base_exception/models/base_exception_method.py b/base_exception/models/base_exception_method.py index 2814872fe6b..79f24a66bb5 100644 --- a/base_exception/models/base_exception_method.py +++ b/base_exception/models/base_exception_method.py @@ -90,9 +90,16 @@ 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" - ) + # The registry is not ready yet when modules are being installed or + # updated: the ongoing transaction holds exclusive locks on the tables + # it has just modified (ALTER TABLE ...), so any query made through a + # second connection would wait forever on those locks (and the locks + # cannot be released, as the current thread is the one waiting). + # Use the current cursor in that case, as already done when running + # tests. + test_mode = ( + config["test_enable"] or not self.env.registry.ready + ) 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: From 696561e2f0af9916b4e5c1586bb553bea405bbc6 Mon Sep 17 00:00:00 2001 From: sergio-teruel Date: Mon, 14 Sep 2026 13:56:00 +0200 Subject: [PATCH 6/8] [FIX] base_exception: MissingError on records created in the ongoing transaction detect_exceptions() opens a second, genuinely independent DB connection (self.env.registry.cursor()) to write exception flags so they survive a rollback of the ongoing transaction. It then re-derived main_records through that same second connection (self.with_env(new_env)), which crashes with MissingError whenever _get_main_records() has to read a field on records created earlier in the SAME ongoing transaction and not yet committed (e.g. sale.order.line._get_main_records() reading order_id right after the line was created by write()). Typical trigger: adding a product to an already confirmed sale order. sale_exception.write() re-runs detect_exceptions() on order_line whenever order_line changes on a confirmed order; the new line only exists in the ongoing transaction, so the second connection can't see it yet. Resolve main_records once through the original (main) cursor, which does see the just-created records, and only rebind that already-resolved recordset to the new environment for the exception_ids check. --- .../models/base_exception_method.py | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/base_exception/models/base_exception_method.py b/base_exception/models/base_exception_method.py index 79f24a66bb5..040eb9fced2 100644 --- a/base_exception/models/base_exception_method.py +++ b/base_exception/models/base_exception_method.py @@ -100,6 +100,10 @@ def detect_exceptions(self): test_mode = ( config["test_enable"] or not self.env.registry.ready ) and not self.env.context.get("test_base_exception") + # Resolve the main records (e.g. the sale order behind a sale order + # line) using the current cursor, which sees records created earlier + # in this same transaction even before they are committed. + main_records = self._get_main_records() # 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: @@ -118,9 +122,21 @@ def detect_exceptions(self): ) # 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(): + # ongoing transaction. + # Re-derive main_records through new_env rather than re-running + # self.with_env(new_env)._get_main_records(): when self are + # records just created earlier in the ongoing (not yet committed) + # transaction (e.g. a line added while editing a confirmed sale + # order), new_cr is a genuinely separate DB connection that + # cannot see them yet, and _get_main_records() traversal + # (e.g. sale.order.line -> order_id) would raise MissingError. + # main_records itself was already resolved above through the + # current cursor, so only rebinding it to new_env is needed here. + main_records_new_env = main_records.with_env(new_env) + if ( + rules_to_add + or main_records_new_env._must_raise_exception_after_detection() + ): raise_exception = True if raise_exception: raise BaseExceptionError( From 5181008716c8e27a83f4c024a1f71ed0014aa4a1 Mon Sep 17 00:00:00 2001 From: sergio-teruel Date: Thu, 17 Sep 2026 11:12:17 +0200 Subject: [PATCH 7/8] [FIX] base_exception: declare decorator as external python dependency Odoo 19.0 dropped 'decorator' from its core requirements.txt (present in18.0). base_exception's tests use it directly (via the decorator package, not through odoo_test_helper as in 18.0), so CI fails with ModuleNotFoundError: No module named 'decorator' when running the forward-ported test suite. --- base_exception/__manifest__.py | 3 +++ requirements.txt | 1 + 2 files changed, 4 insertions(+) diff --git a/base_exception/__manifest__.py b/base_exception/__manifest__.py index 4766770d94c..26b47796533 100644 --- a/base_exception/__manifest__.py +++ b/base_exception/__manifest__.py @@ -17,6 +17,9 @@ "depends": ["base_setup"], "maintainers": ["hparfr", "sebastienbeau"], "license": "AGPL-3", + "external_dependencies": { + "python": ["decorator"], + }, "data": [ "security/base_exception_security.xml", "security/ir.model.access.csv", diff --git a/requirements.txt b/requirements.txt index f63d53a4c42..016321d27ad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ # generated from manifests external_dependencies cryptography dataclasses +decorator numpy odoorpc openupgradelib From 0f939e64b3812a3d8470b47120c71f6260661a23 Mon Sep 17 00:00:00 2001 From: sergio-teruel Date: Thu, 17 Sep 2026 11:14:28 +0200 Subject: [PATCH 8/8] [FIX] base_exception: use 19.0's registry_enter_test_mode API Odoo 19.0 renamed Registry.enter_test_mode(cr)/leave_test_mode() to TransactionCase.registry_enter_test_mode()/registry_leave_test_mode(), with automatic cleanup registration. test_rollback_main_transaction (forward-ported from 18.0 in an earlier commit) still used the old API, failing with AttributeError: 'Registry' object has no attribute 'enter_test_mode'. --- base_exception/tests/test_base_exception.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/base_exception/tests/test_base_exception.py b/base_exception/tests/test_base_exception.py index a1c65845823..829f64ef6ab 100644 --- a/base_exception/tests/test_base_exception.py +++ b/base_exception/tests/test_base_exception.py @@ -193,8 +193,7 @@ def test_blocking_exception(self): def test_rollback_main_transaction(self): # Get new TestCursor - self.registry.enter_test_mode(self.cr) - self.addCleanup(self.registry.leave_test_mode) + self.registry_enter_test_mode() with ( self.registry.cursor() as new_cr, patch(