From 00211a8655eabc7ac131ad1a1b23e64c7dacd379 Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Mon, 6 Apr 2026 17:59:45 +0200 Subject: [PATCH 1/8] [IMP] sale_exception: Use hook to popup exception after rollback With OCA/server-tools#3590 changing the way exceptions are detected, the error raising doesn't allow to call `_popup_exception` as we used to, but we can use the new hook to have the popup displayed smoothly. --- sale_exception/models/sale_order.py | 7 ++++--- sale_exception/tests/test_sale_exception.py | 3 --- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/sale_exception/models/sale_order.py b/sale_exception/models/sale_order.py index 73e69c3d16f..343cfa8fb33 100644 --- a/sale_exception/models/sale_order.py +++ b/sale_exception/models/sale_order.py @@ -47,11 +47,12 @@ def sale_check_exception(self): if orders: orders._check_exception() + def _must_popup_exception(self): + return self.env.company.sale_exception_show_popup + def action_confirm(self): if self.detect_exceptions(): - if not self.env.company.sale_exception_show_popup: - return - return self._popup_exceptions() + return return super().action_confirm() def action_draft(self): diff --git a/sale_exception/tests/test_sale_exception.py b/sale_exception/tests/test_sale_exception.py index e5f0606fe36..422810d8e1e 100644 --- a/sale_exception/tests/test_sale_exception.py +++ b/sale_exception/tests/test_sale_exception.py @@ -158,9 +158,6 @@ def test_exception_partner_sale_warning(self): sale_order2 = sale_order.copy() self.env.company.sale_exception_show_popup = True result = sale_order2.action_confirm() - self.assertEqual( - result.get("xml_id"), "sale_exception.action_sale_exception_confirm" - ) self.assertEqual(sale_order2.state, "draft") self.assertTrue(sale_order2.exception_ids.filtered(lambda x: x == exception)) From ddf97155a6ead66fcab0f5d164945e404e92d254 Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Thu, 23 Apr 2026 19:55:50 +0200 Subject: [PATCH 2/8] [FIX] sale_exception: Store exception in dedicated cursor to be committed Since main transaction is rollbacked in the new version of base_exception we need a dedicated transaction to persist the exceptions on the sale order lines. --- sale_exception/models/sale_order_line.py | 28 +++++++++++++-------- sale_exception/tests/test_sale_exception.py | 4 ++- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/sale_exception/models/sale_order_line.py b/sale_exception/models/sale_order_line.py index 4881c4f716b..03714003975 100644 --- a/sale_exception/models/sale_order_line.py +++ b/sale_exception/models/sale_order_line.py @@ -4,6 +4,7 @@ import html from odoo import api, fields, models +from odoo.api import Environment from odoo.fields import Command @@ -53,15 +54,20 @@ def _reverse_field(self): def _detect_exceptions(self, rule): records = super()._detect_exceptions(rule) - # Thanks to the new flush of odoo 13.0, queries will be optimized - # together at the end even if we update the exception_ids many times. - # On previous versions, this could be unoptimized. - lines_to_remove_exception = (self - records).filtered( - lambda line: rule.id in line.exception_ids.ids - ) - lines_to_remove_exception.exception_ids = [Command.unlink(rule.id)] - lines_to_add_exception = records.filtered( - lambda line: rule.id not in line.exception_ids.ids - ) - lines_to_add_exception.exception_ids = [Command.link(rule.id)] + # 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) + lines_to_remove_exception = (self - records).filtered( + lambda line: rule.id in line.exception_ids.ids + ) + lines_to_remove_exception.with_env(new_env).exception_ids = [ + Command.unlink(rule.id) + ] + lines_to_add_exception = records.filtered( + lambda line: rule.id not in line.exception_ids.ids + ) + lines_to_add_exception.with_env(new_env).exception_ids = [ + Command.link(rule.id) + ] return records.mapped("order_id") diff --git a/sale_exception/tests/test_sale_exception.py b/sale_exception/tests/test_sale_exception.py index 422810d8e1e..930adda6161 100644 --- a/sale_exception/tests/test_sale_exception.py +++ b/sale_exception/tests/test_sale_exception.py @@ -157,7 +157,7 @@ def test_exception_partner_sale_warning(self): partner.sale_warn = "warning" sale_order2 = sale_order.copy() self.env.company.sale_exception_show_popup = True - result = sale_order2.action_confirm() + sale_order2.action_confirm() self.assertEqual(sale_order2.state, "draft") self.assertTrue(sale_order2.exception_ids.filtered(lambda x: x == exception)) @@ -232,3 +232,5 @@ def test_exception_no_free(self): so_except_confirm.action_confirm() self.assertFalse(sale_order.ignore_exception) self.assertTrue(sale_order.state == "draft") + + # TODO: Add test on storage of exception on sale.order.line through new cursor From 7083b9abfeb70b6277e0093ec4d78c26239369ae Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Tue, 28 Apr 2026 17:07:41 +0200 Subject: [PATCH 3/8] Fix tests using decorators from base_exception --- sale_exception/tests/common.py | 26 +++++++++++++++++++++ sale_exception/tests/test_multi_records.py | 10 ++++++++ sale_exception/tests/test_sale_exception.py | 23 ++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 sale_exception/tests/common.py diff --git a/sale_exception/tests/common.py b/sale_exception/tests/common.py new file mode 100644 index 00000000000..0c41ac231c4 --- /dev/null +++ b/sale_exception/tests/common.py @@ -0,0 +1,26 @@ +# 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 + + +@contextmanager +def mock_detect_exception_method_env(self, env=None): + if env is None: + env = self.env + with patch( + "odoo.addons.sale_exception.models.sale_order_line.Environment" + ) as mocked_env: + mocked_env.return_value = env + yield + + +@decorator +def patch_detect_exception_method_env(func, self): + with mock_detect_exception_method_env(self): + return func(self) diff --git a/sale_exception/tests/test_multi_records.py b/sale_exception/tests/test_multi_records.py index 310a69900e8..b8cc0c1291c 100644 --- a/sale_exception/tests/test_multi_records.py +++ b/sale_exception/tests/test_multi_records.py @@ -5,6 +5,13 @@ from odoo import Command from odoo.tests import TransactionCase +from odoo.addons.base_exception.tests.common import ( + patch_base_exception_method_env, + swallow_base_exception_error, +) + +from .common import patch_detect_exception_method_env + class TestSaleExceptionMultiRecord(TransactionCase): @classmethod @@ -17,6 +24,9 @@ def setUpClass(cls): } ) + @patch_base_exception_method_env + @patch_detect_exception_method_env + @swallow_base_exception_error def test_sale_order_exception(self): exception_no_sol = self.env.ref("sale_exception.excep_no_sol") exception_no_free = self.env.ref("sale_exception.excep_no_free") diff --git a/sale_exception/tests/test_sale_exception.py b/sale_exception/tests/test_sale_exception.py index 930adda6161..6432d9fb3a0 100644 --- a/sale_exception/tests/test_sale_exception.py +++ b/sale_exception/tests/test_sale_exception.py @@ -4,10 +4,18 @@ # Copyright 2021 Tecnativa - Víctor Martínez # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + from odoo import Command from odoo.exceptions import UserError, ValidationError from odoo.tests import Form, TransactionCase +from odoo.addons.base_exception.tests.common import ( + patch_base_exception_method_env, + swallow_base_exception_error, +) + +from .common import patch_detect_exception_method_env + class TestSaleException(TransactionCase): @classmethod @@ -20,6 +28,9 @@ def setUpClass(cls): } ) + @patch_base_exception_method_env + @patch_detect_exception_method_env + @swallow_base_exception_error def test_sale_order_exception(self): self.sale_exception_confirm = self.env["sale.exception.confirm"] @@ -146,6 +157,9 @@ def _create_sale_order(self, partner, product): line_form.product_id = product return order_form.save() + @patch_base_exception_method_env + @patch_detect_exception_method_env + @swallow_base_exception_error def test_exception_partner_sale_warning(self): exception = self.env.ref("sale_exception.exception_partner_sale_warning") exception.active = True @@ -161,6 +175,9 @@ def test_exception_partner_sale_warning(self): self.assertEqual(sale_order2.state, "draft") self.assertTrue(sale_order2.exception_ids.filtered(lambda x: x == exception)) + @patch_base_exception_method_env + @patch_detect_exception_method_env + @swallow_base_exception_error def test_exception_partner_sale_warning_no_popup(self): exception = self.env.ref("sale_exception.exception_partner_sale_warning") exception.active = True @@ -177,6 +194,9 @@ def test_exception_partner_sale_warning_no_popup(self): self.assertEqual(sale_order2.state, "draft") self.assertTrue(sale_order2.exception_ids.filtered(lambda x: x == exception)) + @patch_base_exception_method_env + @patch_detect_exception_method_env + @swallow_base_exception_error def test_exception_product_sale_warning(self): exception = self.env.ref("sale_exception.exception_product_sale_warning") exception.active = True @@ -190,6 +210,9 @@ def test_exception_product_sale_warning(self): sale_order2.detect_exceptions() self.assertTrue(sale_order2.exception_ids.filtered(lambda x: x == exception)) + @patch_base_exception_method_env + @patch_detect_exception_method_env + @swallow_base_exception_error def test_exception_no_free(self): # No allow ignoring exceptions if the "is_blocking" field is checked self.sale_exception_confirm = self.env["sale.exception.confirm"] From 92f3d396b04a25fb13e1279e6f8b49055cc8c338 Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Tue, 28 Apr 2026 17:09:34 +0200 Subject: [PATCH 4/8] Fix display of popup on exceptions raised by sale.order.line --- sale_exception/models/sale_order_line.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sale_exception/models/sale_order_line.py b/sale_exception/models/sale_order_line.py index 03714003975..c73251e2f7c 100644 --- a/sale_exception/models/sale_order_line.py +++ b/sale_exception/models/sale_order_line.py @@ -71,3 +71,7 @@ def _detect_exceptions(self, rule): Command.link(rule.id) ] return records.mapped("order_id") + + def _detect_exception_get_exc_class_values(self): + res = super()._detect_exception_get_exc_class_values() + return dict(res, target_model="sale.order") From 86ac42d41537d71144325cba05e5c66f829e2be9 Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Tue, 28 Apr 2026 17:50:56 +0200 Subject: [PATCH 5/8] [DROPME] test_requirements.txt --- test-requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/test-requirements.txt b/test-requirements.txt index 4ad8e0eceaa..8d1df0769bd 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1 +1,2 @@ odoo-test-helper +odoo-addon-base_exception @ git+https://github.com/OCA/server-tools.git@refs/pull/3590/head#subdirectory=base_exception From 0ec20a9dbd4349d5de98da31e4bb54b9dadc2ad4 Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Wed, 29 Apr 2026 07:55:24 +0200 Subject: [PATCH 6/8] Add / fix tests --- sale_exception/tests/test_sale_exception.py | 36 +++++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/sale_exception/tests/test_sale_exception.py b/sale_exception/tests/test_sale_exception.py index 6432d9fb3a0..06435152d89 100644 --- a/sale_exception/tests/test_sale_exception.py +++ b/sale_exception/tests/test_sale_exception.py @@ -5,16 +5,19 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) -from odoo import Command +from odoo import SUPERUSER_ID, Command +from odoo.api import Environment from odoo.exceptions import UserError, ValidationError from odoo.tests import Form, TransactionCase +from odoo.addons.base_exception.exceptions import BaseExceptionError from odoo.addons.base_exception.tests.common import ( + mock_base_exception_method_env, patch_base_exception_method_env, swallow_base_exception_error, ) -from .common import patch_detect_exception_method_env +from .common import mock_detect_exception_method_env, patch_detect_exception_method_env class TestSaleException(TransactionCase): @@ -256,4 +259,31 @@ def test_exception_no_free(self): self.assertFalse(sale_order.ignore_exception) self.assertTrue(sale_order.state == "draft") - # TODO: Add test on storage of exception on sale.order.line through new cursor + def test_sale_order_line_exception_stored(self): + exception = self.env.ref("sale_exception.excep_no_dumping").sudo() + exception.active = True + partner = self.env.ref("base.res_partner_1") + product = self.env.ref("product.product_product_6") + product.standard_price = 10.0 + sale_order = self._create_sale_order(partner=partner, product=product) + sale_order.order_line.price_unit = 5.0 + self.registry.enter_test_mode(self.cr) + self.addCleanup(self.registry.leave_test_mode) + with ( + self.registry.cursor() as new_cr, + ): + new_env = Environment(new_cr, SUPERUSER_ID, {"module": "sale_exception"}) + with ( + # Use new_env created here instead of the one in base_exception_method + mock_base_exception_method_env(self, env=new_env), + mock_detect_exception_method_env(self, env=new_env), + self.assertRaises(BaseExceptionError), + ): + sale_order.action_confirm() + new_cr._savepoint = None + self.assertFalse(sale_order.exception_ids) + self.assertTrue(sale_order.with_env(new_env).exception_ids) + self.assertFalse(sale_order.order_line.exception_ids) + self.assertTrue(sale_order.order_line.with_env(new_env).exception_ids) + self.assertNotEqual(sale_order.state, "sale") + self.assertNotEqual(sale_order.with_env(new_env).state, "sale") From 6d824d0a488e9cc6821eb92d8a039abadf80ebe8 Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Wed, 29 Apr 2026 13:46:43 +0200 Subject: [PATCH 7/8] [FIX] sale_exception_product_sale_manufactured_for: Adapt tests --- .../tests/test_sale_exception.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/sale_exception_product_sale_manufactured_for/tests/test_sale_exception.py b/sale_exception_product_sale_manufactured_for/tests/test_sale_exception.py index 6efc9a0d1bf..feff65fd852 100644 --- a/sale_exception_product_sale_manufactured_for/tests/test_sale_exception.py +++ b/sale_exception_product_sale_manufactured_for/tests/test_sale_exception.py @@ -2,13 +2,17 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) from odoo.addons.base.tests.common import BaseCommon +from odoo.addons.base_exception.tests.common import ( + patch_base_exception_method_env, + swallow_base_exception_error, +) class TestSaleException(BaseCommon): @classmethod def setUpClass(cls): super().setUpClass() - + cls.env.context = dict(cls.env.context, test_base_exception=True) cls.exception = cls.env.ref( "sale_exception_product_sale_manufactured_for.exception_partner_can_order" ) @@ -40,6 +44,8 @@ def setUpClass(cls): } ) + @patch_base_exception_method_env + @swallow_base_exception_error def test_commercial_partner_not_valid(self): self.sale.partner_id.commercial_partner_id = self.env.ref("base.res_partner_2") self.sale.action_confirm() @@ -55,6 +61,8 @@ def test_commercial_partner_is_valid(self): self.assertEqual(self.sale.state, "sale") self.assertFalse(self.sale.exception_ids) + @patch_base_exception_method_env + @swallow_base_exception_error def test_commercial_partner_empty(self): self.sale.partner_id.commercial_partner_id = False self.sale.action_confirm() From 283d3e196a5ee4dda6b89e9f3e670f41a1db510a Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Wed, 29 Apr 2026 16:54:16 +0200 Subject: [PATCH 8/8] fixup! Add / fix tests --- sale_exception/models/sale_order_line.py | 10 +++++++++- sale_exception/tests/test_sale_exception.py | 6 +++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/sale_exception/models/sale_order_line.py b/sale_exception/models/sale_order_line.py index c73251e2f7c..2ef15a264a9 100644 --- a/sale_exception/models/sale_order_line.py +++ b/sale_exception/models/sale_order_line.py @@ -6,6 +6,7 @@ from odoo import api, fields, models from odoo.api import Environment from odoo.fields import Command +from odoo.tools import config class SaleOrderLine(models.Model): @@ -54,10 +55,17 @@ def _reverse_field(self): def _detect_exceptions(self, rule): records = super()._detect_exceptions(rule) + 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) + new_env = ( + Environment(new_cr, self.env.uid, self.env.context) + if not test_mode + else self.env + ) lines_to_remove_exception = (self - records).filtered( lambda line: rule.id in line.exception_ids.ids ) diff --git a/sale_exception/tests/test_sale_exception.py b/sale_exception/tests/test_sale_exception.py index 06435152d89..dc6869bf4e8 100644 --- a/sale_exception/tests/test_sale_exception.py +++ b/sale_exception/tests/test_sale_exception.py @@ -24,7 +24,11 @@ class TestSaleException(TransactionCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.env = cls.env( + context=dict( + cls.env.context, test_base_exception=True, tracking_disable=True + ) + ) cls.default_pl = cls.env["product.pricelist"].create( { "name": "Public Pricelist",