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):
"""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
46 changes: 42 additions & 4 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.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,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
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):
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 {
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
66 changes: 65 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.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,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")
Loading