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/models/sale_order_line.py b/sale_exception/models/sale_order_line.py index 4881c4f716b..2ef15a264a9 100644 --- a/sale_exception/models/sale_order_line.py +++ b/sale_exception/models/sale_order_line.py @@ -4,7 +4,9 @@ import html 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): @@ -53,15 +55,31 @@ 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 + test_mode = config["test_enable"] and not self.env.context.get( + "test_base_exception" ) - 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) + if not test_mode + else self.env + ) + 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") + + def _detect_exception_get_exc_class_values(self): + res = super()._detect_exception_get_exc_class_values() + return dict(res, target_model="sale.order") 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 e5f0606fe36..dc6869bf4e8 100644 --- a/sale_exception/tests/test_sale_exception.py +++ b/sale_exception/tests/test_sale_exception.py @@ -4,22 +4,40 @@ # 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 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 mock_detect_exception_method_env, patch_detect_exception_method_env + 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", } ) + @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 +164,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 @@ -157,13 +178,13 @@ 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() - self.assertEqual( - result.get("xml_id"), "sale_exception.action_sale_exception_confirm" - ) + sale_order2.action_confirm() 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 @@ -180,6 +201,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 @@ -193,6 +217,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"] @@ -235,3 +262,32 @@ def test_exception_no_free(self): so_except_confirm.action_confirm() self.assertFalse(sale_order.ignore_exception) self.assertTrue(sale_order.state == "draft") + + 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") 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() 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