\>
diff --git a/base_exception/static/description/index.html b/base_exception/static/description/index.html
index 39aacf98508..54c84e8ede8 100644
--- a/base_exception/static/description/index.html
+++ b/base_exception/static/description/index.html
@@ -372,7 +372,7 @@ Exception Rule
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
-!! source digest: sha256:f26be2731bb07680ada96c7a82498292ac0bc49c68965087476b3d354e6acc9e
+!! source digest: sha256:bf43c9d6f42f7195b276ef11de74a1a145886fbc5928d55fe35e8763fb5fd866
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

This module provide an abstract model to manage customizable exceptions
@@ -438,6 +438,7 @@
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/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 2c869160a90..cbc9bed14ac 100644
--- a/base_exception/tests/test_base_exception.py
+++ b/base_exception/tests/test_base_exception.py
@@ -2,17 +2,28 @@
# Copyright 2020 Hibou Corp.
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
+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
@@ -51,6 +62,7 @@ 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 +73,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 +95,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 +109,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 +136,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 +146,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
@@ -146,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")