Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[IMP] product_configurator: Price for custom values with formula #111

Open
wants to merge 2 commits into
base: 16.0
Choose a base branch
from
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
29 changes: 29 additions & 0 deletions product_configurator/models/product_attribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.tools.safe_eval import test_python_expr


class ProductAttribute(models.Model):
Expand Down Expand Up @@ -83,6 +84,34 @@
)
uom_id = fields.Many2one(comodel_name="uom.uom", string="Unit of Measure")
image = fields.Binary()
configurator_extra_price_formula = fields.Text(
string="Extra price formula",
help="Formula evaluated when computing "
"the extra price "
"for the custom value of this attribute.\n"
"The following variables are available:\n"
"- attribute: this attribute,\n"
"- config_session: the configuration session that configured the product,\n"
"- custom_value: the value provided by the user,\n"
"- product: the configured product,\n"
"The computed price "
"must be assigned to the `price` variable.",
)

@api.constrains(
"configurator_extra_price_formula",
)
def _constrain_configurator_extra_price_formula(self):
"""Check syntax of added formula for 'exec' evaluation."""
for attribute in self:
price_formula = attribute.configurator_extra_price_formula
if price_formula:
error_message = test_python_expr(
expr=price_formula,
mode="exec",
)
if error_message:
raise ValidationError(error_message)

Check warning on line 114 in product_configurator/models/product_attribute.py

View check run for this annotation

Codecov / codecov/patch

product_configurator/models/product_attribute.py#L114

Added line #L114 was not covered by tests

# TODO prevent the same attribute from being defined twice on the
# attribute lines
Expand Down
63 changes: 53 additions & 10 deletions product_configurator/models/product_config.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import logging
from ast import literal_eval

from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
from odoo.tools.misc import flatten, formatLang
from odoo.tools.safe_eval import safe_eval

_logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -343,6 +343,7 @@ class ProductConfigSession(models.Model):

@api.depends(
"value_ids",
"custom_value_ids.price",
"product_tmpl_id.list_price",
"product_tmpl_id.attribute_line_ids",
"product_tmpl_id.attribute_line_ids.value_ids",
Expand All @@ -369,12 +370,11 @@ def _get_custom_vals_dict(self):
{attribute_id: parsed_custom_value}"""
custom_vals = {}
for val in self.custom_value_ids:
if val.attribute_id.custom_type in ["float", "integer"]:
custom_vals[val.attribute_id.id] = literal_eval(val.value)
elif val.attribute_id.custom_type == "binary":
custom_vals[val.attribute_id.id] = val.attachment_ids
attribute = val.attribute_id
if attribute.custom_type == "binary":
custom_vals[attribute.id] = val.attachment_ids
else:
custom_vals[val.attribute_id.id] = val.value
custom_vals[attribute.id] = val.eval()
return custom_vals

def _compute_config_step_name(self):
Expand Down Expand Up @@ -414,7 +414,7 @@ def get_cfg_weight(self, value_ids=None, custom_vals=None):
value_ids = self.value_ids.ids

if custom_vals is None:
custom_vals = {}
custom_vals = self._get_custom_vals_dict()

product_tmpl = self.product_tmpl_id

Expand Down Expand Up @@ -840,20 +840,26 @@ def get_cfg_price(self, value_ids=None, custom_vals=None):
value_ids = self.value_ids.ids

if custom_vals is None:
custom_vals = {}
custom_vals = self._get_custom_vals_dict()

session_custom_values = self.custom_value_ids.filtered(
lambda cv: cv.attribute_id.id in custom_vals.keys()
)
custom_prices = session_custom_values.mapped("price")

price_extra = sum(custom_prices)

product_tmpl = self.product_tmpl_id
self = self.with_context(active_id=product_tmpl.id)

value_ids = self.flatten_val_ids(value_ids)

price_extra = 0.0
attr_val_obj = self.env["product.attribute.value"]
av_ids = attr_val_obj.browse(value_ids)
extra_prices = attr_val_obj.get_attribute_value_extra_prices(
product_tmpl_id=product_tmpl.id, pt_attr_value_ids=av_ids
)
price_extra = sum(extra_prices.values())
price_extra += sum(extra_prices.values())
return product_tmpl.list_price + price_extra

def _get_config_image(self, value_ids=None, custom_vals=None, size=None):
Expand Down Expand Up @@ -1629,6 +1635,43 @@ def _compute_val_name(self):
column2="attachment_id",
string="Attachments",
)
price = fields.Float(
compute="_compute_price",
help="Price computed using attribute's 'Extra price formula'.",
)

def _eval_price_formula_variables_dict(self):
"""Variables described in `product.attribute.configurator_extra_price_formula`."""
self.ensure_one()
return {
"attribute": self.attribute_id,
"config_session": self.cfg_session_id,
"custom_value": self.eval(),
"product": self.cfg_session_id.product_id,
}

def _eval_price_formula(self):
self.ensure_one()
price_formula = self.attribute_id.configurator_extra_price_formula
if price_formula:
variables_dict = self._eval_price_formula_variables_dict()
safe_eval(
price_formula,
globals_dict=variables_dict,
mode="exec",
nocopy=True,
)
price = variables_dict.get("price", 0)
else:
price = 0
return price

@api.depends(
"value",
)
def _compute_price(self):
for custom_value in self:
custom_value.price = custom_value._eval_price_formula()

def eval(self):
"""Return custom value evaluated using the related custom field type"""
Expand Down
1 change: 1 addition & 0 deletions product_configurator/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
from . import test_product_attribute
from . import test_product_config
from . import test_wizard
from . import test_custom_attribute_price
112 changes: 112 additions & 0 deletions product_configurator/tests/test_custom_attribute_price.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Copyright 2024 Simone Rubino - Aion Tech
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from odoo.fields import first
from odoo.tests import Form, TransactionCase
from odoo.tools.safe_eval import safe_eval


class TestCustomAttributePrice(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# The product attribute view only shows configuration fields
# (such as `val_custom`)
# when called with a specific context
# that is set by this action
configuration_attributes_action = cls.env.ref(
"product_configurator.action_attributes_view"
)
action_eval_context = configuration_attributes_action._get_eval_context()
configuration_attribute_context = safe_eval(
configuration_attributes_action.context, globals_dict=action_eval_context
)
configuration_attribute_model = cls.env["product.attribute"].with_context(
**configuration_attribute_context
)

custom_attribute_form = Form(configuration_attribute_model)
custom_attribute_form.name = "Test custom attribute"
custom_attribute_form.val_custom = True
cls.custom_attribute = custom_attribute_form.save()
cls.custom_attribute_value = cls.env.ref(
"product_configurator.custom_attribute_value"
)

regular_attribute_form = Form(configuration_attribute_model)
regular_attribute_form.name = "Test custom attribute"
regular_attribute_form.val_custom = False
with regular_attribute_form.value_ids.new() as value:
value.name = "Test value 1"
cls.regular_attribute = regular_attribute_form.save()

product_template_form = Form(cls.env["product.template"])
product_template_form.name = "Test configurable product"
with product_template_form.attribute_line_ids.new() as custom_line:
custom_line.attribute_id = cls.custom_attribute
with product_template_form.attribute_line_ids.new() as regular_line:
regular_line.attribute_id = cls.regular_attribute
regular_line.value_ids.add(first(cls.regular_attribute.value_ids))
product_template = product_template_form.save()
product_template.config_ok = True
cls.product_template = product_template

def test_integer_multiplier_formula(self):
"""The custom attribute has a formula `custom_value` * `multiplier`,
check that the configuration's price is computed correctly.
"""
# Arrange
regular_attribute = self.regular_attribute

multiplier = 5
custom_value = 3
custom_attribute = self.custom_attribute
custom_attribute.custom_type = "integer"
custom_attribute.configurator_extra_price_formula = (
"price = custom_value * %s" % multiplier
)
custom_attribute_value = self.custom_attribute_value

product_template = self.product_template

# Act: configure the product
wizard_action = product_template.configure_product()
wizard = self.env[wizard_action["res_model"]].browse(wizard_action["res_id"])
self.assertEqual(wizard.state, "select")
wizard.action_next_step()
self.assertEqual(wizard.state, "configure")
fields_prefixes = wizard._prefixes
field_prefix = fields_prefixes.get("field_prefix")
custom_field_prefix = fields_prefixes.get("custom_field_prefix")
wizard.write(
{
field_prefix
+ str(regular_attribute.id): first(regular_attribute.value_ids).id,
field_prefix + str(custom_attribute.id): custom_attribute_value.id,
custom_field_prefix + str(custom_attribute.id): custom_value,
}
)
wizard.action_config_done()

# Assert
configured_session = wizard.config_session_id
configured_custom_value = configured_session.custom_value_ids
self.assertEqual(configured_custom_value.price, custom_value * multiplier)

expected_configuration_price = (
product_template.list_price + configured_custom_value.price
)
self.assertEqual(configured_session.price, expected_configuration_price)

# Act: change the custom value
new_custom_value = 2
configured_custom_value.value = "%s" % new_custom_value

# Assert: the price has changed
new_expected_custom_price = new_custom_value * multiplier
self.assertEqual(configured_custom_value.price, new_expected_custom_price)

new_expected_configuration_price = (
product_template.list_price + configured_custom_value.price
)
self.assertEqual(configured_session.price, new_expected_configuration_price)
6 changes: 3 additions & 3 deletions product_configurator/tests/test_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,10 +342,10 @@ def test_12_fields_get(self):

def test_13_fields_view_get(self):
product_config_wizard = self._check_wizard_nxt_step()
product_config_wizard.fields_view_get()
product_config_wizard.get_view()
product_config_wizard.with_context(
wizard_id=product_config_wizard.id
).fields_view_get()
).get_view()
# custom value
# custom value
self.attr_line_fuel.custom = True
Expand Down Expand Up @@ -408,7 +408,7 @@ def test_13_fields_view_get(self):
product_config_wizard_1.action_next_step()
product_config_wizard_1.with_context(
wizard_id=product_config_wizard_1.id
).fields_view_get()
).get_view()

def test_14_unlink(self):
product_config_wizard = self._check_wizard_nxt_step()
Expand Down
3 changes: 3 additions & 0 deletions product_configurator/views/product_attribute_view.xml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@
<group>
<field name="search_ok" />
</group>
<group>
<field name="configurator_extra_price_formula" />
</group>
</group>
</page>
</xpath>
Expand Down
1 change: 1 addition & 0 deletions product_configurator/views/product_config_view.xml
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@
<tree editable="bottom">
<field name="attribute_id" />
<field name="value" />
<field name="price" />
<field
name="attachment_ids"
widget="many2many_tags"
Expand Down
Loading
Loading