Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions base_exception/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,9 @@
"views/base_exception_view.xml",
],
"installable": True,
"assets": {
"web.assets_backend": [
"base_exception/static/src/js/base_exception.esm.js",
],
},
}
8 changes: 8 additions & 0 deletions base_exception/exceptions.py
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
23 changes: 14 additions & 9 deletions base_exception/models/base_exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,22 @@ def _compute_exceptions_summary(self):
else:
rec.exceptions_summary = False

def _must_popup_exception(self):

Copy link
Copy Markdown

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 ?

must express a necessity to enforce, not just the intent to show it.

"""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],
Expand All @@ -85,7 +90,7 @@ def _popup_exceptions(self):
}
}
)
return action
return action_dict

@api.model
def _get_popup_action(self):
Expand Down
57 changes: 52 additions & 5 deletions base_exception/models/base_exception_method.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)


Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self.env(cr=cr) instead ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to call Environment here so that we can patch it in mock_base_exception_method_env

@SilvioC2C SilvioC2C May 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can use self.env(cr=new_cr) by mocking Environment.__new__(). Though I think it's fine as it is.

@divad1196 divad1196 Jun 5, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

env(cr=cr) does exactly what you are doing it: It creates a copy.
https://github.com/odoo/odoo/blob/18.0/odoo/api.py#L650

@SilvioC2C SilvioC2C Jun 5, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@divad1196 beware:

  • if you use odoo.api.Environemnt(...), it is resolved to odoo.api.Environment.__new__()
  • if you use self.env(...), it is resolved to odoo.api.Environment.__call__() which then calls odoo.api.Environment.__new__()
  • odoo.api.Environment.__new__() will not always create a new copy: if an odoo.api.Environment object with the same params (cursor, uid, context, sudo flag, original uid) already exists for the current transaction, such object is returned:
# using the shell
env: <odoo.api.Environment object at 0x7f539c402270>
odoo: <module 'odoo' from '/odoo/src/odoo/__init__.py'>
openerp: <module 'odoo' from '/odoo/src/odoo/__init__.py'>
self: res.users(1,)
Python 3.12.13 (main, May 19 2026, 23:48:44) [GCC 14.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
(Console)
>>> env_1 = env(self.env.cr, self.env.uid, self.env.context, self.env.su)
>>> env_2 = env(self.env.cr, self.env.uid, self.env.context, self.env.su)
>>> env_1 is env_2
True

IMHO using self.env() or odoo.api.Environment() is pretty much the same; self.env() just adds one more step. If we need to mock the usage of both of them, then I think mocking odoo.api.Environment.__new__() should be the preferred solution.

@divad1196 divad1196 Jun 19, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SilvioC2C I know how they work, but it's irrelevant:
they both ultimately end up calling __new__.

The point of using self.env(cr=cr) is that the __call__ will fetch the other parameters so you don't have to pass them manually like done here. That's the only difference.
This is why I sent the link to the code.

Environment uses what's called a multiton pattern (like a singleton based on paramters).
The code for it is here:

for env in transaction.envs:
    if (env.cr, env.uid, env.su, env.uid_origin, env.context) == (cr, uid, su, uid_origin, context):
        return env

and it won't apply because we passed a new cursor object to env(cr=cr)

TLDR:

There is no reason to not use env(cr=cr)

if not test_mode
else self.env

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we use self.env, this means we cannot test to rollback?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure to understand your concern here 🤨

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.
And we make an edge-case for when the function is in a test test_mode=True.

=> Environment(new_cr, self.env.uid, self.env.context) is only used in production, not in testing.

In testing, we use self.env => We cannot test the double cursor trick to ensure it works

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is tested through the use of context key test_base_exception.

We need this to avoid messing with other modules being tested while this one is installed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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?
The current comment only explain why we create a new environment for production, it does not explain why the same method cannot be used in testing.

)
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
Comment thread
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 {
Expand Down
1 change: 1 addition & 0 deletions base_exception/readme/CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@
- Kevin Khao \<<kevin.khao@akretion.com>\>
- Laurent Mignon \<<laurent.mignon@acsone.eu>\>
- Do Anh Duy \<<duyda@trobz.com>\>
- Akim Juillerat \<<akim.juillerat@camptocamp.com>\>
44 changes: 44 additions & 0 deletions base_exception/static/src/js/base_exception.esm.js
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});
40 changes: 40 additions & 0 deletions base_exception/tests/common.py
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)
5 changes: 5 additions & 0 deletions base_exception/tests/purchase_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})

Expand Down
70 changes: 69 additions & 1 deletion base_exception/tests/test_base_exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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(
{
Expand All @@ -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(
{
Expand All @@ -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):
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.po.exception_ids to have different values according to the chosen Environment?

self.assertNotEqual(self.po.state, "purchase")
self.assertNotEqual(self.po.with_env(new_env).state, "purchase")
Loading