-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
[18.0][FIX] base_exception: Rollback transaction if we detect exceptions #3590
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 18.0
Are you sure you want to change the base?
Changes from all commits
22e7010
1f9b009
d59669b
5df7af7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 import Command, _, 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,54 @@ 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We need to call
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you can use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @divad1196 beware:
IMHO using There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @SilvioC2C I know how they work, but it's irrelevant: The point of using
for env in transaction.envs:
if (env.cr, env.uid, env.su, env.uid_origin, env.context) == (cr, uid, su, uid_origin, context):
return envand it won't apply because we passed a new cursor object to TLDR:There is no reason to not use |
||
| if not test_mode | ||
| else self.env | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if we use
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure to understand your concern here 🤨 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, I was indeed not clear. The code here is not in a test, it's the production. => In testing, we use
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is tested through the use of context key We need this to avoid messing with other modules being tested while this one is installed. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @grindtildeath I don't understand how this impacts other modules. Can you explain? |
||
| ) | ||
| 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 | ||
|
simahawk marked this conversation as resolved.
|
||
| 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): | ||
| 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 { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Comment on lines
+213
to
+214
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For the sake of maintainability, would you please write a comment explaining why we expect |
||
| self.assertNotEqual(self.po.state, "purchase") | ||
| self.assertNotEqual(self.po.with_env(new_env).state, "purchase") | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_must_popup_exception->_should_popup_exception?mustexpress a necessity to enforce, not just the intent to show it.