diff --git a/base_exception/__manifest__.py b/base_exception/__manifest__.py index 7d3a49458b9..a3f1eadeb53 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.esm.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.py b/base_exception/models/base_exception.py index b15b355fa11..6ff432e5249 100644 --- a/base_exception/models/base_exception.py +++ b/base_exception/models/base_exception.py @@ -66,17 +66,22 @@ 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 {"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.""" - record = self._get_popup_action() - action = record.sudo().read()[0] - action = { - field: value - for field, value in action.items() - if field in record._get_readable_fields() - } - action.update( + # TODO: When migrating, use _for_xml_id instead of this + action = self._get_popup_action() + action_dict = action.sudo()._get_action_dict() + action_dict.update( { "context": { "active_id": self.ids[0], @@ -85,7 +90,7 @@ def _popup_exceptions(self): } } ) - return action + return action_dict @api.model def _get_popup_action(self): diff --git a/base_exception/models/base_exception_method.py b/base_exception/models/base_exception_method.py index 61923098aaf..b3acdc8f929 100644 --- a/base_exception/models/base_exception_method.py +++ b/base_exception/models/base_exception_method.py @@ -4,14 +4,19 @@ # 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 from odoo import _, api, models +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 + _logger = logging.getLogger(__name__) @@ -84,12 +89,45 @@ 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 + 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) + 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(): + 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( + 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 + ) or any(rule.is_blocking for rule in self.exception_ids) + @api.model def _exception_rule_eval_context(self, rec): return { 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/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..83d470d6f6b 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.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,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 + mock_base_exception_method_env(self, 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")