Skip to content
Open
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.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
29 changes: 25 additions & 4 deletions base_exception/models/base_exception_method.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@
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.safe_eval import safe_eval

from ..exceptions import BaseExceptionError

_logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -84,12 +87,30 @@ 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)
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.

Suggested change
new_env = Environment(new_cr, self.env.uid, self.env.context)
records_env = records.with_env(self.env(cr=new_cr))

to avoid all the with_env everytime you read/write, initialize a records_env variable.

also you can use self.env(cr=) like this

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.

Actually we have many sets of records:

  1. self
  2. values of rules_to_add
  3. values of rules_to_add

For each one we need to use the new cursor, so I guess it makes sense to create the new_env this way. AFAIU self.env(cr=new_cr) would just do the same, but we need to reuse it many times anyway

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
Comment on lines +99 to +104
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.

Maybe extract this to a method records_env._should_raise_exception ?

if raise_exception:
raise BaseExceptionError("Exceptions detected")
return all_exception_ids

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.mapped("exception_ids"))

@api.model
def _exception_rule_eval_context(self, rec):
return {
Expand Down
62 changes: 62 additions & 0 deletions base_exception/static/src/js/base_exception.js
Original file line number Diff line number Diff line change
@@ -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;
}
},
});
41 changes: 41 additions & 0 deletions base_exception/tests/test_base_exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,20 @@
# 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_test_helper import FakeModelLoader

from odoo.exceptions import UserError, ValidationError
from odoo.tests import TransactionCase

from ..exceptions import BaseExceptionError


class TestBaseException(TransactionCase):
def setUp(self):
Expand Down Expand Up @@ -47,10 +56,29 @@ def setUp(self):
}
)

@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)

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 +89,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 +111,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 +125,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 +152,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 +162,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 Down
Loading