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
1 change: 1 addition & 0 deletions base_exception/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ Contributors
- Kevin Khao <kevin.khao@akretion.com>
- Laurent Mignon <laurent.mignon@acsone.eu>
- Do Anh Duy <duyda@trobz.com>
- Akim Juillerat <akim.juillerat@camptocamp.com>

Other credits
-------------
Expand Down
8 changes: 8 additions & 0 deletions base_exception/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,19 @@
"depends": ["base_setup"],
"maintainers": ["hparfr", "sebastienbeau"],
"license": "AGPL-3",
"external_dependencies": {
"python": ["decorator"],
},
"data": [
"security/base_exception_security.xml",
"security/ir.model.access.csv",
"wizard/base_exception_confirm_view.xml",
"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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Once you adapt the code as commented here: https://github.com/OCA/server-tools/pull/3729/changes#r4036710316 please remove the TODO.

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
80 changes: 75 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.fields import Domain
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,77 @@ 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
# The registry is not ready yet when modules are being installed or
# updated: the ongoing transaction holds exclusive locks on the tables
# it has just modified (ALTER TABLE ...), so any query made through a
# second connection would wait forever on those locks (and the locks
# cannot be released, as the current thread is the one waiting).
# Use the current cursor in that case, as already done when running
# tests.
test_mode = (
config["test_enable"] or not self.env.registry.ready
) and not self.env.context.get("test_base_exception")
# Resolve the main records (e.g. the sale order behind a sale order
# line) using the current cursor, which sees records created earlier
# in this same transaction even before they are committed.
main_records = self._get_main_records()
# 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": [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.
# Re-derive main_records through new_env rather than re-running
# self.with_env(new_env)._get_main_records(): when self are
# records just created earlier in the ongoing (not yet committed)
# transaction (e.g. a line added while editing a confirmed sale
# order), new_cr is a genuinely separate DB connection that
# cannot see them yet, and _get_main_records() traversal
# (e.g. sale.order.line -> order_id) would raise MissingError.
# main_records itself was already resolved above through the
# current cursor, so only rebinding it to new_env is needed here.
main_records_new_env = main_records.with_env(new_env)
if (
rules_to_add
or main_records_new_env._must_raise_exception_after_detection()
):
raise_exception = True
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>\>
1 change: 1 addition & 0 deletions base_exception/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ <h3><a class="toc-backref" href="#toc-entry-5">Contributors</a></h3>
<li>Kevin Khao &lt;<a class="reference external" href="mailto:kevin.khao&#64;akretion.com">kevin.khao&#64;akretion.com</a>&gt;</li>
<li>Laurent Mignon &lt;<a class="reference external" href="mailto:laurent.mignon&#64;acsone.eu">laurent.mignon&#64;acsone.eu</a>&gt;</li>
<li>Do Anh Duy &lt;<a class="reference external" href="mailto:duyda&#64;trobz.com">duyda&#64;trobz.com</a>&gt;</li>
<li>Akim Juillerat &lt;<a class="reference external" href="mailto:akim.juillerat&#64;camptocamp.com">akim.juillerat&#64;camptocamp.com</a>&gt;</li>
</ul>
</div>
<div class="section" id="other-credits">
Expand Down
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
Loading
Loading