Skip to content
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
23 changes: 16 additions & 7 deletions base_exception/models/base_exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,26 @@ 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 {}

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 = {
action = self._get_popup_action()
action_dict = action.sudo().read()[0]
action_dict = {
field: value
for field, value in action.items()
if field in record._get_readable_fields()
for field, value in action_dict.items()
if field in action._get_readable_fields()
}
action.update(
action_dict.update(
{
"context": {
"active_id": self.ids[0],
Expand All @@ -85,7 +94,7 @@ def _popup_exceptions(self):
}
}
)
return action
return action_dict

@api.model
def _get_popup_action(self):
Expand Down
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
80 changes: 80 additions & 0 deletions base_exception/static/src/js/base_exception.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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();
},
});

function getActiveRecordInfo(controller) {
const model = controller.model;
const root = model?.root;
return {
root,
resModel: root?.resModel,
resId: root?.resId,
};
}

async function popUpException() {
const controller = activeForm.controller;
const orm = controller.env.services.orm;

const {resModel, resId} = getActiveRecordInfo(controller);
if (!resModel || !resId) return false;
const actionService = controller.env.services.action;
const action = await orm.call(resModel, "action_popup_exceptions", [[resId]]);
if (!action) return false;

await actionService.doAction(action);

return true;
}

async function refreshExceptionIdsField() {
const controller = activeForm.controller;
if (!controller) return false;

const {root, resModel, resId} = getActiveRecordInfo(controller);
if (!resModel || !resId) return false;
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();
await popUpException();
} else {
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