diff --git a/pandoc-3.1.11.1-1-amd64.deb b/pandoc-3.1.11.1-1-amd64.deb new file mode 100644 index 0000000000..1af3187f70 Binary files /dev/null and b/pandoc-3.1.11.1-1-amd64.deb differ diff --git a/product_configurator/README.md b/product_configurator/README.md new file mode 100644 index 0000000000..affc7d9035 --- /dev/null +++ b/product_configurator/README.md @@ -0,0 +1,25 @@ +#Odoo Product Configurator + +This module is Dynamic configuration wizard for Odoo back-end and the foundation for +external configuration interfaces such 'website_product_configurator'. + +By itself this module does not configure custom products but offers the basis for +generating, validating, updating configurable products using configuration interfaces. + +# Features + +- Inhibition of automatically created variants. +- Extension of attribute lines to offer required, custom and multiple selection. +- Configuration / Compatibility rules between attributes. +- Separation of attributes in different steps. +- Images for intermediate and final configurations. +- Managing active configuration sessions for external configurators +- Set of helper methods required for any Odoo configuration module. + +# Usage + +This module is Dynamic configuration wizard for Odoo back-end and the foundation for +external configuration interfaces such 'website_product_configurator'. + +By itself this module does not configure custom products but offers the basis for +generating, validating, updating configurable products using configuration interfaces. diff --git a/product_configurator/README.rst b/product_configurator/README.rst new file mode 100644 index 0000000000..488f1ca0f6 --- /dev/null +++ b/product_configurator/README.rst @@ -0,0 +1,86 @@ +==================== +Product Configurator +==================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:6adc7da27d5c08fae323a693949845877a252c0604b9f14345ceb6685c59fb55 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fproduct--configurator-lightgray.png?logo=github + :target: https://github.com/OCA/product-configurator/tree/17.0/product_configurator + :alt: OCA/product-configurator +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/product-configurator-17-0/product-configurator-17-0-product_configurator + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/product-configurator&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module has all the mechanics to support product configuration. It +serves as a base dependency for configuration interfaces. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Pledra + +Contributors +------------ + +- Nikul Chaudhary +- Murtaza Mithaiwala + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-PCatinean| image:: https://github.com/PCatinean.png?size=40px + :target: https://github.com/PCatinean + :alt: PCatinean + +Current `maintainer `__: + +|maintainer-PCatinean| + +This module is part of the `OCA/product-configurator `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/product_configurator/__init__.py b/product_configurator/__init__.py new file mode 100644 index 0000000000..ad2c86ac0b --- /dev/null +++ b/product_configurator/__init__.py @@ -0,0 +1,4 @@ +from . import models +from . import wizard + +from .init_hook import post_init_hook diff --git a/product_configurator/__manifest__.py b/product_configurator/__manifest__.py new file mode 100644 index 0000000000..d8b15b7d33 --- /dev/null +++ b/product_configurator/__manifest__.py @@ -0,0 +1,54 @@ +{ + "name": "Product Configurator", + "version": "17.0.1.0.0", + "category": "Generic Modules/Base", + "summary": "Base for product configuration interface modules", + "author": "Pledra, Odoo Community Association (OCA)", + "license": "AGPL-3", + "website": "https://github.com/OCA/product-configurator", + "external_dependencies": { + "python": [ + "mako", + ] + }, + "depends": ["account"], + "data": [ + "security/configurator_security.xml", + "security/ir.model.access.csv", + "views/res_config_settings_view.xml", + "data/menu_configurable_product.xml", + "data/product_attribute.xml", + "data/ir_sequence_data.xml", + "data/ir_config_parameter_data.xml", + "views/product_view.xml", + "views/product_attribute_view.xml", + "views/product_config_view.xml", + "wizard/product_configurator_view.xml", + ], + "assets": { + "web.assets_backend": [ + "/product_configurator/static/src/scss/form_widget.scss", + "/product_configurator/static/src/js/form_widgets.esm.js", + "/product_configurator/static/src/js/boolean_button_widget.esm.js", + "/product_configurator/static/src/js/boolean_button_widget.xml", + "/product_configurator/static/src/js/relational_fields.esm.js", + "/product_configurator/static/src/js/view.js", + ] + }, + "demo": [ + "demo/product_template.xml", + "demo/product_attribute.xml", + "demo/product_config_domain.xml", + "demo/product_config_lines.xml", + "demo/product_config_step.xml", + "demo/config_image_ids.xml", + ], + "images": ["static/description/cover.png"], + "post_init_hook": "post_init_hook", + # "qweb": ["static/xml/create_button.xml"], + "development_status": "Beta", + "maintainers": ["PCatinean"], + "installable": True, + "application": True, + "auto_install": False, +} diff --git a/product_configurator/data/ir_config_parameter_data.xml b/product_configurator/data/ir_config_parameter_data.xml new file mode 100644 index 0000000000..f686211787 --- /dev/null +++ b/product_configurator/data/ir_config_parameter_data.xml @@ -0,0 +1,9 @@ + + + + product_configurator.manager_product_configuration_settings + True + + diff --git a/product_configurator/data/ir_sequence_data.xml b/product_configurator/data/ir_sequence_data.xml new file mode 100644 index 0000000000..805d5c689e --- /dev/null +++ b/product_configurator/data/ir_sequence_data.xml @@ -0,0 +1,9 @@ + + + + Configuration Session + product.config.session + CS + 4 + + diff --git a/product_configurator/data/menu_configurable_product.xml b/product_configurator/data/menu_configurable_product.xml new file mode 100644 index 0000000000..63e21736ca --- /dev/null +++ b/product_configurator/data/menu_configurable_product.xml @@ -0,0 +1,132 @@ + + + + + + + + + Configurable Templates + ir.actions.act_window + product.template + kanban,tree,form + + {'default_config_ok': True, 'custom_create_variant': True, 'search_default_filter_config_ok': 1} + + + + + + Configured Variants + ir.actions.act_window + product.product + kanban,form,tree + + + {'default_config_ok': True, 'custom_create_variant': True, 'search_default_filter_config_ok': 1} + + + + + + Settings + ir.actions.act_window + res.config.settings + form + inline + + {'module' : 'product_configurator'} + + + + + + Configuration Steps + ir.actions.act_window + product.config.step + tree,form + + + + + + Configuration Restrictions + ir.actions.act_window + product.config.domain + tree,form + + + + + + Configuration Sessions + ir.actions.act_window + product.config.session + tree,form + + + + + diff --git a/product_configurator/data/product_attribute.xml b/product_configurator/data/product_attribute.xml new file mode 100644 index 0000000000..5b7df2a417 --- /dev/null +++ b/product_configurator/data/product_attribute.xml @@ -0,0 +1,15 @@ + + + + + Custom + + + + + Custom + + + + + diff --git a/product_configurator/demo/config_image_ids.xml b/product_configurator/demo/config_image_ids.xml new file mode 100644 index 0000000000..47d6d2f0b2 --- /dev/null +++ b/product_configurator/demo/config_image_ids.xml @@ -0,0 +1,156 @@ + + + + + + + Coupé Red + + + + + + + Coupé Silver + + + + + + + Coupé Black + + + + + + + Coupé Red Rims 384 + + + + + + + Coupé Red Rims 387 + + + + + + + Coupé Silver Rims 384 + + + + + + + Coupé Silver Rims 387 + + + + + + + Coupé Black Rims 384 + + + + + + + Coupé Black Rims 387 + + + + + + diff --git a/product_configurator/demo/product_attribute.xml b/product_configurator/demo/product_attribute.xml new file mode 100644 index 0000000000..ded2454ebf --- /dev/null +++ b/product_configurator/demo/product_attribute.xml @@ -0,0 +1,408 @@ + + + + + + + Fuel + + + + Gasoline + + + + + Diesel + + + + + + + Engine + + + + + + 218i + + + + + + 220i + + + + + + 228i + + + + + + M235i + + + + + + M235i xDrive + + + + + + + + 218d + + + + + + 220d + + + + + + 220d xDrive + + + + + + 225d + + + + + + + + Lines + + + + Sport Line + + + + + + Model Sport Line + + + + + + Luxury Line + + + + + + Model Luxury Line + + + + + + Model M Sport + + + + + + Model Advantage + + + + + + + + Paint Color + + + + Red + + + + + Silver + + + + + + Black + + + + + + + Rims + + + + V-spoke 16" + + + + + V-spoke 18" + + + + + Double-spoke 18" + + + + + + + Tapistry + + + + Black + + + + + Oyster/Black + + + + + Coral Red/Black + + + + + + + Transmission + + + + Automatic (Steptronic) + + + + + + Automatic Sport (Steptronic) + + + + + + + + Options + + + + Armrest + + + + + + Smoker Package + + + + + + Sunroof + + + + + + Tow hook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/product_configurator/demo/product_config_domain.xml b/product_configurator/demo/product_config_domain.xml new file mode 100644 index 0000000000..e57d75f2aa --- /dev/null +++ b/product_configurator/demo/product_config_domain.xml @@ -0,0 +1,84 @@ + + + + + + + + + Gasoline + + + + + + in + and + + + + + + + Diesel + + + + + + in + and + + + + + + + + 218i Engine + + + + + + in + and + + + + + + + + Luxury Lines + + + + + + in + and + + + + diff --git a/product_configurator/demo/product_config_lines.xml b/product_configurator/demo/product_config_lines.xml new file mode 100644 index 0000000000..616bb7225e --- /dev/null +++ b/product_configurator/demo/product_config_lines.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/product_configurator/demo/product_config_step.xml b/product_configurator/demo/product_config_step.xml new file mode 100644 index 0000000000..797b7ebdc8 --- /dev/null +++ b/product_configurator/demo/product_config_step.xml @@ -0,0 +1,81 @@ + + + + + + + Engine + + + + Body + + + + Lines + + + + Interior + + + + Extras + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/product_configurator/demo/product_template.xml b/product_configurator/demo/product_template.xml new file mode 100644 index 0000000000..e972842155 --- /dev/null +++ b/product_configurator/demo/product_template.xml @@ -0,0 +1,270 @@ + + + + + + + + BMW + + + + + + 2 Series + + consu + + + + + + + + + Sport Line + consu + + + + + + Luxury Line + consu + + + + + + Model Sport Line + consu + + + + + + Model Luxury Line + consu + + + + + + Model M Sport + consu + + + + + + Model Advantage + consu + + + + + + Automatic Transmission Steptronic + consu + + + + + + Sport Automatic Transmission Steptronic + consu + + + + + + Sunroof + consu + + + + + + Armrest + consu + + + + + + Towhook + consu + + + + + + Smoker Package + consu + + + + + + 218i Coupé + consu + + + + + + 220i Coupé + consu + + + + + + 228i Coupé + consu + + + + + + M235i Coupé + consu + + + + + + M235i xDrive Coupe + consu + + + + + + 218d Coupé + consu + + + + + + 220d Coupé + consu + + + + + + 220d xDrive Coupé + consu + + + + + + 225d Coupé + consu + + + + + + Silver Paint + consu + + + + + diff --git a/product_configurator/i18n/product_configurator.pot b/product_configurator/i18n/product_configurator.pot new file mode 100644 index 0000000000..9306ecbeac --- /dev/null +++ b/product_configurator/i18n/product_configurator.pot @@ -0,0 +1,1515 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * product_configurator +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product.py:0 +#, python-format +msgid "" +"\n" +"Attribute/s: %s" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product.py:0 +#, python-format +msgid "" +"\n" +"Restriction: %s" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product.py:0 +#, python-format +msgid "" +"\n" +"Value/s: %s\n" +msgstr "" + +#. module: product_configurator +#: model:product.template,name:product_configurator.bmw_2_series +msgid "2 Series" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_218d +msgid "218d" +msgstr "" + +#. module: product_configurator +#: model:product.template,name:product_configurator.product_engine_218d_coupe_product_template +msgid "218d Coupé" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_218i +msgid "218i" +msgstr "" + +#. module: product_configurator +#: model:product.template,name:product_configurator.product_engine_218i_coupe_product_template +msgid "218i Coupé" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_220d +msgid "220d" +msgstr "" + +#. module: product_configurator +#: model:product.template,name:product_configurator.product_engine_220d_coupe_product_template +msgid "220d Coupé" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_220d_xdrive +msgid "220d xDrive" +msgstr "" + +#. module: product_configurator +#: model:product.template,name:product_configurator.product_engine_220d_xdrive_coupe_product_template +msgid "220d xDrive Coupé" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_220i +msgid "220i" +msgstr "" + +#. module: product_configurator +#: model:product.template,name:product_configurator.product_engine_220i_coupe_product_template +msgid "220i Coupé" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_225d +msgid "225d" +msgstr "" + +#. module: product_configurator +#: model:product.template,name:product_configurator.product_engine_225d_coupe_product_template +msgid "225d Coupé" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_228i +msgid "228i" +msgstr "" + +#. module: product_configurator +#: model:product.template,name:product_configurator.product_engine_228i_coupe_product_template +msgid "228i Coupé" +msgstr "" + +#. module: product_configurator +#: model_terms:ir.ui.view,arch_db:product_configurator.product_template_kanban_view_inherited +msgid "" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute__active +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute_value__active +msgid "Active" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product.py:0 +#, python-format +msgid "" +"All attribute values used in attribute value lines must be defined in the " +"attribute lines of the template" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,help:product_configurator.field_product_attribute__val_custom +msgid "Allow custom value for this attribute?" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,help:product_configurator.field_product_template_attribute_line__custom +msgid "Allow custom values for this attribute?" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,help:product_configurator.field_product_attribute__multi +#: model:ir.model.fields,help:product_configurator.field_product_template_attribute_line__multi +msgid "Allow selection of multiple values for this attribute?" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product_config.py:0 +#, python-format +msgid "An error occurred while parsing value for attribute %s" +msgstr "" + +#. module: product_configurator +#: model_terms:ir.ui.view,arch_db:product_configurator.product_configurator_form +msgid "" +"Are you sure? This will remove your current configuration for this template!" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_armrest +#: model:product.template,name:product_configurator.product_2_series_armrest_product_template +msgid "Armrest" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields.selection,name:product_configurator.selection__product_attribute__custom_type__binary +msgid "Attachment" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session_custom_value__attachment_ids +msgid "Attachments" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute_value_line__attribute_id +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain_line__attribute_id +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session_custom_value__attribute_id +msgid "Attribute" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_product__config_line_ids +#: model:ir.model.fields,field_description:product_configurator.field_product_template__config_line_ids +msgid "Attribute Dependencies" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_line__attribute_line_id +msgid "Attribute Line" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_product__attribute_line_val_ids +#: model:ir.model.fields,field_description:product_configurator.field_product_template__attribute_line_val_ids +msgid "Attribute Line Val" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_line__attr_line_val_ids +msgid "Attribute Line Values" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_step_line__attribute_line_ids +msgid "Attribute Lines" +msgstr "" + +#. module: product_configurator +#: model:ir.model,name:product_configurator.model_product_attribute_value +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute_value_line__value_id +msgid "Attribute Value" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_product__attribute_value_line_ids +#: model:ir.model.fields,field_description:product_configurator.field_product_template__attribute_value_line_ids +msgid "Attribute Value Lines" +msgstr "" + +#. module: product_configurator +#: model:ir.actions.act_window,name:product_configurator.variants_action +#: model:ir.ui.menu,name:product_configurator.menu_variants_action_configuration +msgid "Attribute Values" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_template_attribute_value__weight_extra +msgid "Attribute Weight Extra" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product_config.py:0 +#, python-format +msgid "" +"Attribute custom type is binary, attachments are the only accepted values " +"with this custom field type" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product_config.py:0 +#, python-format +msgid "" +"Attribute custom type must be 'binary' for saving attachments to custom " +"value" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,help:product_configurator.field_product_attribute_value__image +msgid "Attribute value image (Display on website for radio buttons)" +msgstr "" + +#. module: product_configurator +#: model:ir.actions.act_window,name:product_configurator.action_attributes_view +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator__attribute_line_ids +#: model:ir.ui.menu,name:product_configurator.menu_attribute_action_configuration +msgid "Attributes" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_steptronic +msgid "Automatic (Steptronic)" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_steptronic_sport +msgid "Automatic Sport (Steptronic)" +msgstr "" + +#. module: product_configurator +#: model:product.template,name:product_configurator.product_2_series_transmission_steptronic_product_template +msgid "Automatic Transmission Steptronic" +msgstr "" + +#. module: product_configurator +#: model_terms:ir.ui.view,arch_db:product_configurator.product_configurator_form +msgid "Back" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_black +#: model:product.attribute.value,name:product_configurator.product_attribute_value_tapistry_black +msgid "Black" +msgstr "" + +#. module: product_configurator +#: model:product.config.step,name:product_configurator.config_step_body +msgid "Body" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,help:product_configurator.field_product_attribute_value__active +msgid "" +"By unchecking the active field you can disable a attribute value without " +"deleting it" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,help:product_configurator.field_product_attribute__active +msgid "" +"By unchecking the active field you can disable a attribute without deleting " +"it" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_product__config_ok +#: model:ir.model.fields,field_description:product_configurator.field_product_template__config_ok +msgid "Can be Configured" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product_config.py:0 +#, python-format +msgid "" +"Cannot conduct search on an empty config session without product_tmpl_id " +"kwarg" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product_config.py:0 +#, python-format +msgid "Cannot have a configuration step defined twice." +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/wizard/product_configurator.py:0 +#, python-format +msgid "" +"Changing the product template while having an active configuration will " +"erase reset/clear all values" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields.selection,name:product_configurator.selection__product_attribute__custom_type__char +msgid "Char" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields.selection,name:product_configurator.selection__product_attribute__custom_type__color +msgid "Color" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain_line__condition +msgid "Condition" +msgstr "" + +#. module: product_configurator +#: model_terms:ir.ui.view,arch_db:product_configurator.product_template_only_form_view_inherited +msgid "Configurable" +msgstr "" + +#. module: product_configurator +#: model:ir.ui.menu,name:product_configurator.menu_product_configurable_product_main +#: model_terms:ir.ui.view,arch_db:product_configurator.product_template_search_view +msgid "Configurable Products" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product.py:0 +#, python-format +msgid "" +"Configurable Products cannot have duplicates (identical attribute values)" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session__product_tmpl_id +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator__product_tmpl_id +msgid "Configurable Template" +msgstr "" + +#. module: product_configurator +#: model:ir.actions.act_window,name:product_configurator.product_configurable_template_action +#: model:ir.ui.menu,name:product_configurator.menu_product_configurable_template_action +msgid "Configurable Templates" +msgstr "" + +#. module: product_configurator +#: model:ir.ui.menu,name:product_configurator.menu_product_configurable_variants_action +msgid "Configurable Variants" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_image__value_ids +#: model:ir.ui.menu,name:product_configurator.menu_product_configurable_settings +msgid "Configuration" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_product__config_image_ids +#: model:ir.model.fields,field_description:product_configurator.field_product_template__config_image_ids +#: model_terms:ir.ui.view,arch_db:product_configurator.product_template_only_form_view_inherited +msgid "Configuration Images" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_product__config_step_line_ids +#: model:ir.model.fields,field_description:product_configurator.field_product_template__config_step_line_ids +msgid "Configuration Lines" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_product__config_name +msgid "Configuration Name" +msgstr "" + +#. module: product_configurator +#: model:ir.actions.act_window,name:product_configurator.product_config_domain_action +#: model:ir.ui.menu,name:product_configurator.menu_product_config_domain_action +#: model_terms:ir.ui.view,arch_db:product_configurator.product_config_domain_form_view +#: model_terms:ir.ui.view,arch_db:product_configurator.product_config_domain_form_view_template +#: model_terms:ir.ui.view,arch_db:product_configurator.product_template_only_form_view_inherited +msgid "Configuration Restrictions" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator__config_session_id +msgid "Configuration Session" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session__name +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator__name +msgid "Configuration Session Number" +msgstr "" + +#. module: product_configurator +#: model:ir.actions.act_window,name:product_configurator.product_config_session +#: model:ir.ui.menu,name:product_configurator.menu_product_config_session +#: model_terms:ir.ui.view,arch_db:product_configurator.product_config_session_form_view +msgid "Configuration Sessions" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session__config_step_name +#: model:ir.model.fields,field_description:product_configurator.field_product_config_step_line__config_step_id +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator__config_step_name +#: model_terms:ir.ui.view,arch_db:product_configurator.config_step_form_view +#: model_terms:ir.ui.view,arch_db:product_configurator.config_step_tree_view +msgid "Configuration Step" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session__config_step +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator__config_step +msgid "Configuration Step ID" +msgstr "" + +#. module: product_configurator +#: model:ir.actions.act_window,name:product_configurator.product_config_steps_action +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator__config_step_ids +#: model:ir.ui.menu,name:product_configurator.menu_product_config_steps_action +#: model_terms:ir.ui.view,arch_db:product_configurator.product_template_only_form_view_inherited +msgid "Configuration Steps" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product_config.py:0 +#, python-format +msgid "Configuration cannot have the same value inserted twice" +msgstr "" + +#. module: product_configurator +#: model:ir.ui.menu,name:product_configurator.menu_product_configurable +#: model_terms:ir.ui.view,arch_db:product_configurator.product_attribute_form_view +#: model_terms:ir.ui.view,arch_db:product_configurator.product_template_only_form_view_inherited +msgid "Configurator" +msgstr "" + +#. module: product_configurator +#: model_terms:ir.ui.view,arch_db:product_configurator.product_template_only_form_view_inherited +msgid "Configure Product" +msgstr "" + +#. module: product_configurator +#: model:ir.actions.act_window,name:product_configurator.product_configurable_variant_action +msgid "Configured Variants" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_tapistry_coral_red_black +msgid "Coral Red/Black" +msgstr "" + +#. module: product_configurator +#: model:product.config.image,name:product_configurator.config_image_3 +msgid "Coupé Black" +msgstr "" + +#. module: product_configurator +#: model:product.config.image,name:product_configurator.config_image_9 +msgid "Coupé Black Rims 384" +msgstr "" + +#. module: product_configurator +#: model:product.config.image,name:product_configurator.config_image_10 +msgid "Coupé Black Rims 387" +msgstr "" + +#. module: product_configurator +#: model:product.config.image,name:product_configurator.config_image_1 +msgid "Coupé Red" +msgstr "" + +#. module: product_configurator +#: model:product.config.image,name:product_configurator.config_image_5 +msgid "Coupé Red Rims 384" +msgstr "" + +#. module: product_configurator +#: model:product.config.image,name:product_configurator.config_image_6 +msgid "Coupé Red Rims 387" +msgstr "" + +#. module: product_configurator +#: model:product.config.image,name:product_configurator.config_image_2 +msgid "Coupé Silver" +msgstr "" + +#. module: product_configurator +#: model:product.config.image,name:product_configurator.config_image_7 +msgid "Coupé Silver Rims 384" +msgstr "" + +#. module: product_configurator +#: model:product.config.image,name:product_configurator.config_image_8 +msgid "Coupé Silver Rims 387" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute_value_line__create_uid +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain__create_uid +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain_line__create_uid +#: model:ir.model.fields,field_description:product_configurator.field_product_config_image__create_uid +#: model:ir.model.fields,field_description:product_configurator.field_product_config_line__create_uid +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session__create_uid +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session_custom_value__create_uid +#: model:ir.model.fields,field_description:product_configurator.field_product_config_step__create_uid +#: model:ir.model.fields,field_description:product_configurator.field_product_config_step_line__create_uid +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator__create_uid +msgid "Created by" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute_value_line__create_date +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain__create_date +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain_line__create_date +#: model:ir.model.fields,field_description:product_configurator.field_product_config_image__create_date +#: model:ir.model.fields,field_description:product_configurator.field_product_config_line__create_date +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session__create_date +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session_custom_value__create_date +#: model:ir.model.fields,field_description:product_configurator.field_product_config_step__create_date +#: model:ir.model.fields,field_description:product_configurator.field_product_config_step_line__create_date +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator__create_date +msgid "Created on" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session__currency_id +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator__currency_id +msgid "Currency" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_template_attribute_line__custom +#: model:product.attribute,name:product_configurator.custom_attribute +#: model:product.attribute.value,name:product_configurator.custom_attribute_value +msgid "Custom" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute__val_custom +msgid "Custom Value" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session__custom_value_ids +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator__custom_value_ids +#: model_terms:ir.ui.view,arch_db:product_configurator.product_attribute_form_view +#: model_terms:ir.ui.view,arch_db:product_configurator.product_config_session_form_view +msgid "Custom Values" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,help:product_configurator.field_product_config_session_custom_value__value +msgid "Custom value held as string" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields.selection,name:product_configurator.selection__product_attribute__custom_type__date +msgid "Date" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields.selection,name:product_configurator.selection__product_attribute__custom_type__datetime +msgid "DateTime" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_template_attribute_line__default_val +msgid "Default Val" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product_attribute.py:0 +#, python-format +msgid "" +"Default values for each attribute line must exist in the attribute values " +"(%(attr_name)s: %(default_val)s)" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product.py:0 +#: code:addons/product_configurator/models/product_config.py:0 +#, python-format +msgid "Default values provided generate an invalid configuration" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute__description +msgid "Description" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,help:product_configurator.field_product_attribute__required +msgid "" +"Determines the required value of this attribute though it can be change on " +"the template level" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_diesel +msgid "Diesel" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute_value_line__display_name +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain__display_name +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain_line__display_name +#: model:ir.model.fields,field_description:product_configurator.field_product_config_image__display_name +#: model:ir.model.fields,field_description:product_configurator.field_product_config_line__display_name +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session__display_name +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session_custom_value__display_name +#: model:ir.model.fields,field_description:product_configurator.field_product_config_step__display_name +#: model:ir.model.fields,field_description:product_configurator.field_product_config_step_line__display_name +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator__display_name +msgid "Display Name" +msgstr "" + +#. module: product_configurator +#: model:ir.model,name:product_configurator.model_product_config_domain_line +msgid "Domain Line for Config Restrictions" +msgstr "" + +#. module: product_configurator +#: model:ir.model,name:product_configurator.model_product_config_domain +msgid "Domain for Config Restrictions" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields.selection,name:product_configurator.selection__product_config_session__state__done +msgid "Done" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_rims_384 +msgid "Double-spoke 18\"" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields.selection,name:product_configurator.selection__product_config_session__state__draft +msgid "Draft" +msgstr "" + +#. module: product_configurator +#: model:product.attribute,name:product_configurator.product_attribute_engine +#: model:product.config.step,name:product_configurator.config_step_engine +msgid "Engine" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product.py:0 +#, python-format +msgid "Error while calculating mako product name: %s" +msgstr "" + +#. module: product_configurator +#: model:product.config.step,name:product_configurator.config_step_extras +msgid "Extras" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute__custom_type +msgid "Field Type" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product_config.py:0 +#, python-format +msgid "Finished configuration session must have a product_id linked" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields.selection,name:product_configurator.selection__product_attribute__custom_type__float +msgid "Float" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product.py:0 +#, python-format +msgid "" +"Following Attribute/Value from restriction are not present in template " +"attributes/values. Please make sure you are adding right restriction" +msgstr "" + +#. module: product_configurator +#: model:product.attribute,name:product_configurator.product_attribute_fuel +msgid "Fuel" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_gasoline +msgid "Gasoline" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,help:product_configurator.field_product_product__mako_tmpl_name +#: model:ir.model.fields,help:product_configurator.field_product_template__mako_tmpl_name +msgid "Generate Name based on Mako Template" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute_value_line__id +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain__id +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain_line__id +#: model:ir.model.fields,field_description:product_configurator.field_product_config_image__id +#: model:ir.model.fields,field_description:product_configurator.field_product_config_line__id +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session__id +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session_custom_value__id +#: model:ir.model.fields,field_description:product_configurator.field_product_config_step__id +#: model:ir.model.fields,field_description:product_configurator.field_product_config_step_line__id +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator__id +msgid "ID" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute__image +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute_value__image +#: model:ir.model.fields,field_description:product_configurator.field_product_config_image__image_1920 +msgid "Image" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_image__image_1024 +msgid "Image 1024" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_image__image_128 +msgid "Image 128" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_image__image_256 +msgid "Image 256" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_image__image_512 +msgid "Image 512" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain__implied_ids +#: model_terms:ir.ui.view,arch_db:product_configurator.product_config_domain_form_view +#: model_terms:ir.ui.view,arch_db:product_configurator.product_config_domain_form_view_template +msgid "Inherited" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields.selection,name:product_configurator.selection__product_attribute__custom_type__integer +msgid "Integer" +msgstr "" + +#. module: product_configurator +#: model:product.config.step,name:product_configurator.config_step_interior +msgid "Interior" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product_config.py:0 +#: code:addons/product_configurator/models/product_config.py:0 +#, python-format +msgid "Invalid Configuration" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_product__config_preset_ok +msgid "Is Preset" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,help:product_configurator.field_product_template_attribute_line__required +msgid "Is this attribute required?" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute_value_line____last_update +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain____last_update +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain_line____last_update +#: model:ir.model.fields,field_description:product_configurator.field_product_config_image____last_update +#: model:ir.model.fields,field_description:product_configurator.field_product_config_line____last_update +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session____last_update +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session_custom_value____last_update +#: model:ir.model.fields,field_description:product_configurator.field_product_config_step____last_update +#: model:ir.model.fields,field_description:product_configurator.field_product_config_step_line____last_update +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator____last_update +msgid "Last Modified on" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute_value_line__write_uid +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain__write_uid +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain_line__write_uid +#: model:ir.model.fields,field_description:product_configurator.field_product_config_image__write_uid +#: model:ir.model.fields,field_description:product_configurator.field_product_config_line__write_uid +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session__write_uid +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session_custom_value__write_uid +#: model:ir.model.fields,field_description:product_configurator.field_product_config_step__write_uid +#: model:ir.model.fields,field_description:product_configurator.field_product_config_step_line__write_uid +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute_value_line__write_date +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain__write_date +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain_line__write_date +#: model:ir.model.fields,field_description:product_configurator.field_product_config_image__write_date +#: model:ir.model.fields,field_description:product_configurator.field_product_config_line__write_date +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session__write_date +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session_custom_value__write_date +#: model:ir.model.fields,field_description:product_configurator.field_product_config_step__write_date +#: model:ir.model.fields,field_description:product_configurator.field_product_config_step_line__write_date +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator__write_date +msgid "Last Updated on" +msgstr "" + +#. module: product_configurator +#: model:product.attribute,name:product_configurator.product_attribute_model_line +#: model:product.config.step,name:product_configurator.config_step_lines +msgid "Lines" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_luxury_line +#: model:product.template,name:product_configurator.product_bmw_luxury_line_product_template +msgid "Luxury Line" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_m235i +msgid "M235i" +msgstr "" + +#. module: product_configurator +#: model:product.template,name:product_configurator.product_engine_m235i_coupe_product_template +msgid "M235i Coupé" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_m235i_xdrive +msgid "M235i xDrive" +msgstr "" + +#. module: product_configurator +#: model:product.template,name:product_configurator.product_engine_m2351_xdrive_coupe_product_template +msgid "M235i xDrive Coupe" +msgstr "" + +#. module: product_configurator +#: model:res.groups,name:product_configurator.group_product_configurator_manager +msgid "Manager" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_product__weight_dummy +#: model:ir.model.fields,field_description:product_configurator.field_product_template__weight_dummy +msgid "Manual Weight" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,help:product_configurator.field_product_template__weight_dummy +msgid "Manual setting of product template weight" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute__max_val +msgid "Max Value" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,help:product_configurator.field_product_attribute__max_val +msgid "Maximum value allowed" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product_attribute.py:0 +#, python-format +msgid "Maximum value must be greater than Minimum value" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute__min_val +msgid "Min Value" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,help:product_configurator.field_product_attribute__min_val +msgid "Minimum value allowed" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_model_advantage +#: model:product.template,name:product_configurator.product_bmw_model_advantage_product_template +msgid "Model Advantage" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_model_luxury_line +#: model:product.template,name:product_configurator.product_bmw_model_luxury_line_product_template +msgid "Model Luxury Line" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_model_m_sport +#: model:product.template,name:product_configurator.product_bmw_model_m_sport_product_template +msgid "Model M Sport" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_model_sport_line +#: model:product.template,name:product_configurator.product_bmw_model_sport_line_product_template +msgid "Model Sport Line" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute__multi +#: model:ir.model.fields,field_description:product_configurator.field_product_template_attribute_line__multi +msgid "Multi" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain__name +#: model:ir.model.fields,field_description:product_configurator.field_product_config_image__name +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session_custom_value__name +#: model:ir.model.fields,field_description:product_configurator.field_product_config_step__name +#: model:ir.model.fields,field_description:product_configurator.field_product_config_step_line__name +msgid "Name" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product_config.py:0 +#, python-format +msgid "New" +msgstr "" + +#. module: product_configurator +#: model_terms:ir.ui.view,arch_db:product_configurator.product_configurator_form +msgid "Next" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product_attribute.py:0 +#, python-format +msgid "" +"On the product %(product)s you cannot associate the value %(value)s with the" +" attribute %(attr)s because they do not match." +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain_line__operator +msgid "Operators" +msgstr "" + +#. module: product_configurator +#: model:product.attribute,name:product_configurator.product_attribute_options +msgid "Options" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_tapistry_oyster_black +msgid "Oyster/Black" +msgstr "" + +#. module: product_configurator +#: model:product.attribute,name:product_configurator.product_attribute_color +msgid "Paint Color" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session__product_preset_id +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator__product_preset_id +msgid "Preset" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session__price +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator__price +msgid "Price" +msgstr "" + +#. module: product_configurator +#: model:ir.model,name:product_configurator.model_product_template +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute_value__product_id +#: model:ir.model.fields,field_description:product_configurator.field_product_config_image__product_tmpl_id +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session__product_id +msgid "Product" +msgstr "" + +#. module: product_configurator +#: model:ir.model,name:product_configurator.model_product_attribute +msgid "Product Attribute" +msgstr "" + +#. module: product_configurator +#: model:ir.model,name:product_configurator.model_product_attribute_value_line +msgid "Product Attribute Value Line" +msgstr "" + +#. module: product_configurator +#: model_terms:ir.ui.view,arch_db:product_configurator.product_attribute_value_form_view +msgid "Product Attribute Values" +msgstr "" + +#. module: product_configurator +#: model:ir.model,name:product_configurator.model_product_config_image +msgid "Product Config Image" +msgstr "" + +#. module: product_configurator +#: model:ir.model,name:product_configurator.model_product_config_line +msgid "Product Config Restrictions" +msgstr "" + +#. module: product_configurator +#: model:ir.model,name:product_configurator.model_product_config_session +msgid "Product Config Session" +msgstr "" + +#. module: product_configurator +#: model:ir.model,name:product_configurator.model_product_config_session_custom_value +msgid "Product Config Session Custom Value" +msgstr "" + +#. module: product_configurator +#: model:ir.model,name:product_configurator.model_product_config_step_line +msgid "Product Config Step Lines" +msgstr "" + +#. module: product_configurator +#: model:ir.model,name:product_configurator.model_product_config_step +msgid "Product Config Steps" +msgstr "" + +#. module: product_configurator +#: model:ir.module.category,name:product_configurator.product_config_category +#: model_terms:ir.ui.view,arch_db:product_configurator.configurator_settings_view_form +msgid "Product Configurator" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator__product_img +msgid "Product Img" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute_value_line__product_tmpl_id +#: model:ir.model.fields,field_description:product_configurator.field_product_config_line__product_tmpl_id +#: model:ir.model.fields,field_description:product_configurator.field_product_config_step_line__product_tmpl_id +msgid "Product Template" +msgstr "" + +#. module: product_configurator +#: model:ir.model,name:product_configurator.model_product_template_attribute_line +msgid "Product Template Attribute Line" +msgstr "" + +#. module: product_configurator +#: model:ir.model,name:product_configurator.model_product_template_attribute_value +msgid "Product Template Attribute Value" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/wizard/product_configurator.py:0 +#, python-format +msgid "Product Template does not have any attribute lines defined" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute_value_line__product_value_ids +msgid "Product Value" +msgstr "" + +#. module: product_configurator +#: model:ir.model,name:product_configurator.model_product_product +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator__product_id +msgid "Product Variant" +msgstr "" + +#. module: product_configurator +#: model:ir.model,name:product_configurator.model_product_configurator +msgid "Product configuration Wizard" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product_config.py:0 +#, python-format +msgid "Product created via configuration wizard" +msgstr "" + +#. module: product_configurator +#: model_terms:ir.ui.view,arch_db:product_configurator.product_form_view_custom_vals_inherit +msgid "Reconfigure Product" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_red +msgid "Red" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute__required +#: model:ir.model.fields,field_description:product_configurator.field_product_template_attribute_line__required +msgid "Required" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product_config.py:0 +#, python-format +msgid "Required attribute '%s' is empty" +msgstr "" + +#. module: product_configurator +#: model_terms:ir.ui.view,arch_db:product_configurator.product_configurator_form +msgid "Reset" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain__domain_line_ids +#: model:ir.model.fields,field_description:product_configurator.field_product_config_line__domain_id +msgid "Restrictions" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product.py:0 +#, python-format +msgid "" +"Restrictions added make the current default values generate an invalid configuration. \n" +"%s" +msgstr "" + +#. module: product_configurator +#: model:product.attribute,name:product_configurator.product_attribute_rims +msgid "Rims" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain_line__domain_id +msgid "Rule" +msgstr "" + +#. module: product_configurator +#: model_terms:ir.ui.view,arch_db:product_configurator.product_config_domain_form_view +#: model_terms:ir.ui.view,arch_db:product_configurator.product_config_domain_form_view_template +msgid "Rules" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute__search_ok +msgid "Searchable" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product_attribute.py:0 +#, python-format +msgid "Selected custom field type '%s' is not searchable" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product_attribute.py:0 +#, python-format +msgid "Selected custom value '%(name)s' must be at least %(min_val)s" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product_attribute.py:0 +#, python-format +msgid "" +"Selected custom value '%(name)s' must be between %(min_val)s and %(max_val)s" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product_attribute.py:0 +#, python-format +msgid "Selected custom value '%(name)s' must be lower than %(max_value)s" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute_value_line__sequence +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain_line__sequence +#: model:ir.model.fields,field_description:product_configurator.field_product_config_image__sequence +#: model:ir.model.fields,field_description:product_configurator.field_product_config_line__sequence +#: model:ir.model.fields,field_description:product_configurator.field_product_config_step_line__sequence +#: model:ir.model.fields,field_description:product_configurator.field_product_template_attribute_line__sequence +msgid "Sequence" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session_custom_value__cfg_session_id +msgid "Session" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,help:product_configurator.field_product_configurator__product_id +msgid "Set only when re-configuring a existing variant" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,help:product_configurator.field_product_config_domain_line__sequence +msgid "Set the order of operations for evaluation domain lines" +msgstr "" + +#. module: product_configurator +#: model:ir.actions.act_window,name:product_configurator.action_product_configurator_configuration +#: model:ir.ui.menu,name:product_configurator.menu_action_product_configurator_configuration +msgid "Settings" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_silver +msgid "Silver" +msgstr "" + +#. module: product_configurator +#: model:product.template,name:product_configurator.product_paint_silver_product_template +msgid "Silver Paint" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_smoker_package +#: model:product.template,name:product_configurator.product_2_series_smoker_package_product_template +msgid "Smoker Package" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product.py:0 +#: code:addons/product_configurator/models/product.py:0 +#, python-format +msgid "" +"Sorry, you are not allowed to create/change this kind of document. For more " +"information please contact your manager." +msgstr "" + +#. module: product_configurator +#: model:product.template,name:product_configurator.product_2_series_transmission_steptronic_sport_product_template +msgid "Sport Automatic Transmission Steptronic" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_sport_line +#: model:product.template,name:product_configurator.product_bmw_sport_line_product_template +msgid "Sport Line" +msgstr "" + +#. module: product_configurator +#: model_terms:ir.ui.view,arch_db:product_configurator.product_template_search_view +msgid "Standard Products" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session__state +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator__state +msgid "State" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_sunroof +#: model:product.template,name:product_configurator.product_2_series_sunroof_product_template +msgid "Sunroof" +msgstr "" + +#. module: product_configurator +#: model:product.attribute,name:product_configurator.product_attribute_tapistry +msgid "Tapistry" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain_line__template_attribute_value_ids +msgid "Template Attribute Values" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_line__template_attribute_ids +msgid "Template Attributes" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields.selection,name:product_configurator.selection__product_attribute__custom_type__text +msgid "Textarea" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product_attribute.py:0 +#, python-format +msgid "" +"The attribute %(attr)s must have at least one value for the product " +"%(product)s." +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,help:product_configurator.field_product_attribute_value_line__attribute_id +msgid "" +"The attribute cannot be changed once the value is used on at least one " +"product." +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,help:product_configurator.field_product_attribute__custom_type +msgid "The type of the custom field generated in the frontend" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/wizard/product_configurator.py:0 +#, python-format +msgid "There was a problem rendering the view (dynamic_form not found)" +msgstr "" + +#. module: product_configurator +#: model_terms:ir.ui.view,arch_db:product_configurator.product_configurator_form +msgid "" +"To reset/change the Preset Please close and start the configuration again" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_tow_hook +msgid "Tow hook" +msgstr "" + +#. module: product_configurator +#: model:product.template,name:product_configurator.product_2_series_towhook_product_template +msgid "Towhook" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain__trans_implied_ids +msgid "Transitively inherits" +msgstr "" + +#. module: product_configurator +#: model:product.attribute,name:product_configurator.product_attribute_transmission +msgid "Transmission" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute__uom_id +msgid "Unit of Measure" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session__user_id +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator__user_id +#: model:res.groups,name:product_configurator.group_product_configurator +msgid "User" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_rims_378 +msgid "V-spoke 16\"" +msgstr "" + +#. module: product_configurator +#: model:product.attribute.value,name:product_configurator.product_attribute_value_rims_387 +msgid "V-spoke 18\"" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session__value_ids +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session_custom_value__value +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator__value_ids +#: model_terms:ir.ui.view,arch_db:product_configurator.product_attribute_value_form_view +msgid "Value" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_domain_line__value_ids +#: model:ir.model.fields,field_description:product_configurator.field_product_config_line__value_ids +msgid "Values" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_attribute_value_line__value_ids +msgid "Values Configuration" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product_config.py:0 +#, python-format +msgid "Values entered for line '%s' generate a incompatible configuration" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product_config.py:0 +#, python-format +msgid "" +"Values must belong to the attribute of the corresponding attribute_line set " +"on the configuration line" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product_attribute.py:0 +#, python-format +msgid "" +"Values provided to the attribute value line are incompatible with the " +"current rules" +msgstr "" + +#. module: product_configurator +#: model_terms:ir.ui.view,arch_db:product_configurator.product_template_only_form_view_inherited +msgid "Variant Name" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_product__mako_tmpl_name +#: model:ir.model.fields,field_description:product_configurator.field_product_template__mako_tmpl_name +msgid "Variant name" +msgstr "" + +#. module: product_configurator +#: model:ir.model,name:product_configurator.model_ir_ui_view +msgid "View" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_config_session__weight +#: model:ir.model.fields,field_description:product_configurator.field_product_configurator__weight +#: model:ir.model.fields,field_description:product_configurator.field_product_product__weight +#: model:ir.model.fields,field_description:product_configurator.field_product_template__weight +msgid "Weight" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,field_description:product_configurator.field_product_product__weight_extra +msgid "Weight Extra" +msgstr "" + +#. module: product_configurator +#: model:ir.model.fields,help:product_configurator.field_product_attribute__search_ok +msgid "" +"When checking for variants with the same configuration, do we include this " +"field in the search?" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product.py:0 +#, python-format +msgid "You cannot have a duplicate configuration for the same value" +msgstr "" + +#. module: product_configurator +#. odoo-python +#: code:addons/product_configurator/models/product_config.py:0 +#: code:addons/product_configurator/models/product_config.py:0 +#, python-format +msgid "You must select at least one attribute in order to configure a product" +msgstr "" diff --git a/product_configurator/init_hook.py b/product_configurator/init_hook.py new file mode 100644 index 0000000000..59e41bdf18 --- /dev/null +++ b/product_configurator/init_hook.py @@ -0,0 +1,10 @@ +import logging + +logger = logging.getLogger(__name__) + + +def post_init_hook(env): + """Transfer existing weight values to weight_dummy after installation + since now the weight field is computed + """ + env.cr.execute("UPDATE product_product SET weight_dummy = weight") diff --git a/product_configurator/models/__init__.py b/product_configurator/models/__init__.py new file mode 100644 index 0000000000..e2129a2d88 --- /dev/null +++ b/product_configurator/models/__init__.py @@ -0,0 +1,4 @@ +from . import product_config +from . import product_attribute +from . import product +from . import ir_ui_view diff --git a/product_configurator/models/ir_ui_view.py b/product_configurator/models/ir_ui_view.py new file mode 100644 index 0000000000..7ad1ae1c95 --- /dev/null +++ b/product_configurator/models/ir_ui_view.py @@ -0,0 +1,11 @@ +from odoo import models + + +class View(models.Model): + _inherit = "ir.ui.view" + + def _validate_tag_button(self, node, name_manager, node_info): + special = node.get("special") + if special and special == "no_save": + return + return super()._validate_tag_button(node, name_manager, node_info) diff --git a/product_configurator/models/product.py b/product_configurator/models/product.py new file mode 100644 index 0000000000..999879b40b --- /dev/null +++ b/product_configurator/models/product.py @@ -0,0 +1,627 @@ +import logging +from collections import Counter +from io import StringIO + +from mako.runtime import Context +from mako.template import Template + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + @api.depends("product_variant_ids.product_tmpl_id") + def _compute_product_variant_count(self): + """For configurable products return the number of variants configured or + 1 as many views and methods trigger only when a template has at least + one variant attached. Since we create them from the template we should + have access to them always""" + result = super()._compute_product_variant_count() + for product_tmpl in self: + config_ok = product_tmpl.config_ok + variant_count = product_tmpl.product_variant_count + if config_ok and not variant_count: + product_tmpl.product_variant_count = 1 + return result + + @api.depends("attribute_line_ids.value_ids") + def _compute_template_attr_vals(self): + """Compute all attribute values added in attribute line on + product template""" + for product_tmpl in self: + if product_tmpl.config_ok: + value_ids = product_tmpl.attribute_line_ids.mapped("value_ids") + product_tmpl.attribute_line_val_ids = value_ids + else: + product_tmpl.attribute_line_val_ids = False + + @api.constrains("attribute_line_ids", "attribute_value_line_ids") + def check_attr_value_ids(self): + """Check attribute lines don't have some attribute value that + is not present in attribute lines of that product template""" + for product_tmpl in self: + if not product_tmpl.env.context.get("check_constraint", True): + continue + attr_val_lines = product_tmpl.attribute_value_line_ids + attr_val_ids = attr_val_lines.mapped("value_ids") + if not attr_val_ids <= product_tmpl.attribute_line_val_ids: + raise ValidationError( + _( + "All attribute values used in attribute value lines " + "must be defined in the attribute lines of the " + "template" + ) + ) + + @api.constrains("attribute_value_line_ids") + def _validate_unique_config(self): + """Check for duplicate configurations for the same + attribute value in image lines""" + for template in self: + attr_val_line_vals = template.attribute_value_line_ids.read( + ["value_id", "value_ids"], load=False + ) + attr_val_line_vals = [ + (line["value_id"], tuple(line["value_ids"])) + for line in attr_val_line_vals + ] + if len(set(attr_val_line_vals)) != len(attr_val_line_vals): + raise ValidationError( + _("You cannot have a duplicate configuration for the same value") + ) + + config_ok = fields.Boolean(string="Can be Configured") + + config_line_ids = fields.One2many( + comodel_name="product.config.line", + inverse_name="product_tmpl_id", + string="Attribute Dependencies", + copy=False, + ) + + config_image_ids = fields.One2many( + comodel_name="product.config.image", + inverse_name="product_tmpl_id", + string="Configuration Images", + copy=True, + ) + + attribute_value_line_ids = fields.One2many( + comodel_name="product.attribute.value.line", + inverse_name="product_tmpl_id", + string="Attribute Value Lines", + copy=True, + ) + + attribute_line_val_ids = fields.Many2many( + comodel_name="product.attribute.value", + compute="_compute_template_attr_vals", + store=False, + ) + + config_step_line_ids = fields.One2many( + comodel_name="product.config.step.line", + inverse_name="product_tmpl_id", + string="Configuration Lines", + copy=False, + ) + + mako_tmpl_name = fields.Text( + string="Variant name", + help="Generate Name based on Mako Template", + copy=True, + ) + + # We are calculating weight of variants based on weight of + # product-template so that no need of compute and inverse on this + weight = fields.Float( + compute="_compute_weight", + inverse="_set_weight", # pylint: disable=C8110 + search="_search_weight", + store=False, + ) + weight_dummy = fields.Float( + string="Manual Weight", + digits="Stock Weight", + help="Manual setting of product template weight", + ) + + def _compute_weight(self): + config_products = self.filtered(lambda template: template.config_ok) + for product in config_products: + product.weight = product.weight_dummy + standard_products = self - config_products + return super(ProductTemplate, standard_products)._compute_weight() + + def _set_weight(self): + for product_tmpl in self: + product_tmpl.weight_dummy = product_tmpl.weight + if not product_tmpl.config_ok: + super(ProductTemplate, product_tmpl)._set_weight() + return + + def _search_weight(self, operator, value): + return [("weight_dummy", operator, value)] + + def _check_default_values(self): + default_val_ids = ( + self.attribute_line_ids.filtered(lambda line: line.default_val) + .mapped("default_val") + .ids + ) + + cfg_session_obj = self.env["product.config.session"] + try: + cfg_session_obj.validate_configuration( + value_ids=default_val_ids, product_tmpl_id=self.id, final=False + ) + except ValidationError as exc: + raise ValidationError(exc.args[0]) from exc + except Exception as exc: + raise ValidationError( + _("Default values provided generate an invalid configuration") + ) from exc + + @api.constrains("config_line_ids", "attribute_line_ids") + def _check_default_value_domains(self): + for template in self: + try: + template._check_default_values() + except ValidationError as exc: + raise ValidationError( + _( + "Restrictions added make the current default values " + "generate an invalid configuration.\ + \n%s" + ) + % (exc.name) + ) from exc + + def toggle_config(self): + for record in self: + record.config_ok = not record.config_ok + + def _create_variant_ids(self): + """Prevent configurable products from creating variants as these serve + only as a template for the product configurator""" + templates = self.filtered(lambda t: not t.config_ok) + if not templates: + return None + return super(ProductTemplate, templates)._create_variant_ids() + + def unlink(self): + """- Prevent the removal of configurable product templates + from variants + - Patch for check access rights of user(configurable products)""" + configurable_templates = self.filtered(lambda template: template.config_ok) + if configurable_templates: + configurable_templates[:1].check_config_user_access() + for config_template in configurable_templates: + variant_unlink = config_template.env.context.get( + "unlink_from_variant", False + ) + if variant_unlink: + self -= config_template + res = super().unlink() + return res + + def copy(self, default=None): + """Copy restrictions, config Steps and attribute lines + ith product template""" + if not default: + default = {} + self = self.with_context(check_constraint=False) + res = super().copy(default=default) + + # Attribute lines + attribute_line_dict = {} + for line in res.attribute_line_ids: + attribute_line_dict.update({line.attribute_id.id: line.id}) + + # Restrictions + for line in self.config_line_ids: + old_restriction = line.domain_id + new_restriction = old_restriction.copy() + config_line_default = { + "product_tmpl_id": res.id, + "domain_id": new_restriction.id, + } + new_attribute_line_id = attribute_line_dict.get( + line.attribute_line_id.attribute_id.id, False + ) + if not new_attribute_line_id: + continue + config_line_default.update({"attribute_line_id": new_attribute_line_id}) + line.copy(config_line_default) + + # Config steps + config_step_line_default = {"product_tmpl_id": res.id} + for line in self.config_step_line_ids: + new_attribute_line_ids = [ + attribute_line_dict.get(old_attr_line.attribute_id.id) + for old_attr_line in line.attribute_line_ids + if old_attr_line.attribute_id.id in attribute_line_dict + ] + if new_attribute_line_ids: + config_step_line_default.update( + {"attribute_line_ids": [(6, 0, new_attribute_line_ids)]} + ) + line.copy(config_step_line_default) + return res + + def configure_product(self): + """launches a product configurator wizard with a linked + template in order to configure new product.""" + return ( + self.with_context(product_tmpl_id_readonly=True) + .with_company(self.env.company) + .create_config_wizard(click_next=False) + ) + + def create_config_wizard( + self, + model_name="product.configurator", + extra_vals=None, + click_next=True, + ): + """create product configuration wizard + - return action to launch wizard + - click on next step based on value of click_next""" + wizard_obj = self.env[model_name] + wizard_vals = {"product_tmpl_id": self.id} + if extra_vals: + wizard_vals.update(extra_vals) + wizard = wizard_obj.create(wizard_vals) + if click_next: + action = wizard.action_next_step() + else: + wizard_obj = wizard_obj.with_context( + wizard_model=model_name, + allow_preset_selection=True, + ) + action = wizard_obj.get_wizard_action(wizard=wizard) + return action + + @api.model + def _check_config_group_rights(self): + """Return True/False from system parameter + - Signals access rights needs to check or not + :Params: return : boolean""" + ICPSudo = self.env["ir.config_parameter"].sudo() + manager_product_configuration_settings = ICPSudo.get_param( + "product_configurator.manager_product_configuration_settings" + ) + return manager_product_configuration_settings + + @api.model + def check_config_user_access(self): + """Check user have access to perform action(create/write/delete) + on configurable products""" + if not self._check_config_group_rights(): + return True + config_manager = self.env.user.has_group( + "product_configurator.group_product_configurator_manager" + ) + user_root = self.env.ref("base.user_root") + user_admin = self.env.ref("base.user_admin") + if ( + config_manager + or self.env.user.id in [user_root.id, user_admin.id] + or self.env.su + ): + return True + raise ValidationError( + _( + "Sorry, you are not allowed to create/change this kind of " + "document. For more information please contact your manager." + ) + ) + + @api.model_create_multi + def create(self, vals_list): + """Patch for check access rights of user(configurable products)""" + for vals in vals_list: + config_ok = vals.get("config_ok", False) + if config_ok: + self.check_config_user_access() + return super().create(vals_list) + + def write(self, vals): + """Patch for check access rights of user(configurable products)""" + change_config_ok = "config_ok" in vals + configurable_templates = self.filtered(lambda template: template.config_ok) + if change_config_ok or configurable_templates: + self[:1].check_config_user_access() + res = super().write(vals) + if "config_step_line_ids" in vals: + miss_attrs = list( + set(self.attribute_line_ids.ids) + - set(self.config_step_line_ids.attribute_line_ids.ids) + ) + if miss_attrs and self.config_step_line_ids: + attrs = [ + x.attribute_id.name + for x in self.env["product.template.attribute.line"].browse( + miss_attrs + ) + ] + raise ValidationError( + _( + "The following attributes are missing from Configuration Steps: %s", + (",".join(attrs)), + ) + ) + couter = [] + for config_step in self.config_step_line_ids: + couter.extend(config_step.attribute_line_ids.ids) + counter = Counter(couter) + duplicates = [] + for k, v in dict(counter).items(): + if v > 1: + duplicates.append(k) + if duplicates: + duplicates = self.env["product.template.attribute.line"].browse( + duplicates + ) + duplicates = ",".join(duplicates.mapped("attribute_id.name")) + raise ValidationError( + _( + "The following attributes have duplicates in Configuration Steps: %s", + (duplicates), + ) + ) + return res + + @api.constrains("config_line_ids") + def _check_config_line_domain(self): + attribute_line_ids = self.attribute_line_ids + tmpl_value_ids = attribute_line_ids.mapped("value_ids") + tmpl_attribute_ids = attribute_line_ids.mapped("attribute_id") + error_message = False + for domain_id in self.config_line_ids.mapped("domain_id"): + domain_attr_ids = domain_id.domain_line_ids.mapped("attribute_id") + domain_value_ids = domain_id.domain_line_ids.mapped("value_ids") + invalid_value_ids = domain_value_ids - tmpl_value_ids + invalid_attribute_ids = domain_attr_ids - tmpl_attribute_ids + if not invalid_value_ids and not invalid_value_ids: + continue + if not error_message: + error_message = _( + "Following Attribute/Value from restriction " + "are not present in template attributes/values. " + "Please make sure you are adding right restriction" + ) + error_message += _("\nRestriction: %s") % (domain_id.name) + error_message += ( + invalid_attribute_ids + and _("\nAttribute/s: %s") + % (", ".join(invalid_attribute_ids.mapped("name"))) + or "" + ) + error_message += ( + invalid_value_ids + and _("\nValue/s: %s\n") % (", ".join(invalid_value_ids.mapped("name"))) + or "" + ) + if error_message: + raise ValidationError(error_message) + + @api.model + def _name_search(self, name, domain=None, operator="ilike", limit=None, order=None): + domain = domain or [] + domain += ["|", ("name", operator, name), ("default_code", operator, name)] + return self._search(domain, limit=limit, order=order) + + +class ProductProduct(models.Model): + _inherit = "product.product" + _rec_name = "config_name" + + def _get_conversions_dict(self): + conversions = {"float": float, "integer": int} + return conversions + + @api.constrains("product_template_attribute_value_ids") + def _check_duplicate_product(self): + """Check for prducts with same attribute values/custom values""" + for product in self: + if not product.config_ok: + continue + + # At the moment, I don't have enough confidence with my + # understanding of binary attributes, so will leave these + # as not matching... + # In theory, they should just work, if they are set to "non search" + # in custom field def! + # TODO: Check the logic with binary attributes + config_session_obj = product.env["product.config.session"] + ptav_ids = product.product_template_attribute_value_ids.mapped( + "product_attribute_value_id" + ) + duplicates = config_session_obj.search_variant( + product_tmpl_id=product.product_tmpl_id, + value_ids=ptav_ids.ids, + ).filtered(lambda p: p.id != product.id) + + if duplicates: + raise ValidationError( + _( + "Configurable Products cannot have duplicates " + "(identical attribute values)" + ) + ) + + def _get_config_name(self): + """Name for configured products + :param: return : String""" + self.ensure_one() + return self.name + + def _get_mako_context(self, buf): + """Return context needed for computing product name based + on mako-tamplate define on it's product template""" + self.ensure_one() + ptav_ids = self.product_template_attribute_value_ids.mapped( + "product_attribute_value_id" + ) + return Context( + buf, + product=self, + attribute_values=ptav_ids, + steps=self.product_tmpl_id.config_step_line_ids, + template=self.product_tmpl_id, + ) + + def _get_mako_tmpl_name(self): + """Compute and return product name based on mako-tamplate + define on it's product template""" + self.ensure_one() + if self.mako_tmpl_name: + try: + mytemplate = Template(self.mako_tmpl_name or "") + buf = StringIO() + ctx = self._get_mako_context(buf) + mytemplate.render_context(ctx) + return buf.getvalue() + except Exception: + _logger.error( + _("Error while calculating mako product name: %s") + % self.display_name + ) + return self.display_name + + @api.depends("product_template_attribute_value_ids.weight_extra") + def _compute_product_weight_extra(self): + for product in self: + product.weight_extra = sum( + product.mapped("product_template_attribute_value_ids.weight_extra") + ) + + def _compute_product_weight(self): + for product in self: + if product.config_ok: + tmpl_weight = product.product_tmpl_id.weight + product.weight = tmpl_weight + product.weight_extra + else: + product.weight = product.weight_dummy + + def _search_product_weight(self, operator, value): + return [("weight_dummy", operator, value)] + + def _inverse_product_weight(self): + """Store weight in dummy field""" + self.weight_dummy = self.weight + + config_name = fields.Char( + string="Configuration Name", compute="_compute_config_name" + ) + weight_extra = fields.Float(compute="_compute_product_weight_extra") + weight_dummy = fields.Float(string="Manual Weight", digits="Stock Weight") + weight = fields.Float( + compute="_compute_product_weight", + inverse="_inverse_product_weight", + search="_search_product_weight", + store=False, + ) + + # product preset + config_preset_ok = fields.Boolean(string="Is Preset") + + def _compute_config_name(self): + """Compute the name of the configurable products and use template + name for others""" + for product in self: + if product.config_ok: + product.config_name = product._get_config_name() + else: + product.config_name = product.name + + def reconfigure_product(self): + """launches a product configurator wizard with a linked + template and variant in order to re-configure an existing product. + It is essentially a shortcut to pre-fill configuration + data of a variant""" + self.ensure_one() + + extra_vals = {"product_id": self.id} + return self.product_tmpl_id.create_config_wizard(extra_vals=extra_vals) + + @api.model + def check_config_user_access(self, mode): + """Check user have access to perform action(create/write/delete) + on configurable products""" + if not self.env["product.template"]._check_config_group_rights(): + return True + config_manager = self.env.user.has_group( + "product_configurator.group_product_configurator_manager" + ) + config_user = self.env.user.has_group( + "product_configurator.group_product_configurator" + ) + user_root = self.env.ref("base.user_root") + user_admin = self.env.ref("base.user_admin") + if ( + config_manager + or (config_user and mode not in ["delete"]) + or self.env.user.id in [user_root.id, user_admin.id] + ): + return True + raise ValidationError( + _( + "Sorry, you are not allowed to create/change this kind of " + "document. For more information please contact your manager." + ) + ) + + def unlink(self): + """- Signal unlink from product variant through context so + removal can be stopped for configurable templates + - check access rights of user(configurable products)""" + config_product = any(p.config_ok for p in self) + if config_product: + self.env["product.product"].check_config_user_access(mode="delete") + ctx = dict(self.env.context, unlink_from_variant=True) + self.env.context = ctx + return super().unlink() + + @api.model_create_multi + def create(self, vals_list): + """Patch for check access rights of user(configurable products)""" + for vals in vals_list: + config_ok = vals.get("config_ok", False) + if config_ok: + self.check_config_user_access(mode="create") + return super().create(vals_list) + + def write(self, vals): + """Patch for check access rights of user(configurable products)""" + change_config_ok = "config_ok" in vals + configurable_products = self.filtered(lambda product: product.config_ok) + if change_config_ok or configurable_products: + self[:1].check_config_user_access(mode="write") + + return super().write(vals) + + def _compute_product_price_extra(self): + standard_products = self.filtered(lambda product: not product.config_ok) + config_products = self - standard_products + if standard_products: + result = super( + ProductProduct, standard_products + )._compute_product_price_extra() + else: + result = None + for product in config_products: + attribute_value_obj = self.env["product.attribute.value"] + value_ids = ( + product.product_template_attribute_value_ids.product_attribute_value_id + ) + extra_prices = attribute_value_obj.get_attribute_value_extra_prices( + product_tmpl_id=product.product_tmpl_id.id, pt_attr_value_ids=value_ids + ) + product.price_extra = sum(extra_prices.values()) + return result diff --git a/product_configurator/models/product_attribute.py b/product_configurator/models/product_attribute.py new file mode 100644 index 0000000000..d270560619 --- /dev/null +++ b/product_configurator/models/product_attribute.py @@ -0,0 +1,583 @@ +import ast + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class ProductAttribute(models.Model): + _inherit = "product.attribute" + _order = "sequence" + + def copy(self, default=None): + """Add ' (Copy)' in name to prevent attribute + having same name while copying""" + if not default: + default = {} + new_attrs = self.env["product.attribute"] + for attr in self: + default.update({"name": attr.name + " (copy)"}) + new_attrs += super(ProductAttribute, attr).copy(default) + return new_attrs + + @api.model + def _get_nosearch_fields(self): + """Return a list of custom field types that do not support searching""" + return ["binary"] + + @api.onchange("custom_type") + def onchange_custom_type(self): + if self.custom_type in self._get_nosearch_fields(): + self.search_ok = False + if self.custom_type not in ("integer", "float"): + self.min_val = False + self.max_val = False + + @api.onchange("val_custom") + def onchange_val_custom_field(self): + if not self.val_custom: + self.custom_type = False + + CUSTOM_TYPES = [ + ("char", "Char"), + ("integer", "Integer"), + ("float", "Float"), + ("text", "Textarea"), + ("color", "Color"), + ("binary", "Attachment"), + ("date", "Date"), + ("datetime", "DateTime"), + ] + + active = fields.Boolean( + default=True, + help="By unchecking the active field you can " + "disable a attribute without deleting it", + ) + min_val = fields.Integer(string="Min Value", help="Minimum value allowed") + max_val = fields.Integer(string="Max Value", help="Maximum value allowed") + + # TODO: Exclude self from result-set of dependency + val_custom = fields.Boolean( + string="Custom Value", help="Allow custom value for this attribute?" + ) + custom_type = fields.Selection( + selection=CUSTOM_TYPES, + string="Field Type", + help="The type of the custom field generated in the frontend", + ) + description = fields.Text(translate=True) + search_ok = fields.Boolean( + string="Searchable", + help="When checking for variants with " + "the same configuration, do we " + "include this field in the search?", + ) + required = fields.Boolean( + default=True, + help="Determines the required value of this " + "attribute though it can be change on " + "the template level", + ) + multi = fields.Boolean( + help="Allow selection of multiple values for this attribute?", + ) + uom_id = fields.Many2one(comodel_name="uom.uom", string="Unit of Measure") + image = fields.Binary() + + # TODO prevent the same attribute from being defined twice on the + # attribute lines + + @api.constrains("custom_type", "search_ok") + def check_searchable_field(self): + for attribute in self: + nosearch_fields = attribute._get_nosearch_fields() + if attribute.custom_type in nosearch_fields and attribute.search_ok: + raise ValidationError( + _("Selected custom field type '%s' is not searchable") + % attribute.custom_type + ) + + def validate_custom_val(self, val): + """Pass in a desired custom value and ensure it is valid. + Probably should check type, etc., but let's assume fine for the moment. + """ + self.ensure_one() + if self.custom_type in ("integer", "float"): + minv = self.min_val + maxv = self.max_val + val = ast.literal_eval(str(val)) + if minv and maxv and (val < minv or val > maxv): + raise ValidationError( + _( + "Selected custom value '%(name)s' must be " + "between %(min_val)s and %(max_val)s" + ) + % ( + { + "name": self.name, + "min_val": self.min_val, + "max_val": self.max_val, + } + ) + ) + elif minv and val < minv: + raise ValidationError( + _("Selected custom value '%(name)s' must be at least %(min_val)s") + % ({"name": self.name, "min_val": self.min_val}) + ) + elif maxv and val > maxv: + raise ValidationError( + _( + "Selected custom value '%(name)s'\ + must be lower than %(max_value)s" + ) + % ({"name": self.name, "max_val": self.max_val + 1}) + ) + + @api.constrains("min_val", "max_val") + def _check_constraint_min_max_value(self): + """Prevent to add Maximun value less than minimum value""" + for attribute in self: + if attribute.custom_type not in ("integer", "float"): + continue + minv = attribute.min_val + maxv = attribute.max_val + if maxv and minv and maxv < minv: + raise ValidationError( + _("Maximum value must be greater than Minimum value") + ) + + +class ProductAttributeLine(models.Model): + _inherit = "product.template.attribute.line" + _order = "product_tmpl_id, sequence, id" + # TODO: Order by dependencies first and then sequence so dependent fields + # do not come before master field + + @api.onchange("attribute_id") + def onchange_attribute(self): + """Set default value of required/multi/cutom from attribute""" + self.value_ids = False + self.required = self.attribute_id.required + self.multi = self.attribute_id.multi + self.custom = self.attribute_id.val_custom + # TODO: Remove all dependencies pointed towards the attribute being + # changed + + @api.onchange("value_ids") + def onchange_values(self): + if self.default_val and self.default_val not in self.value_ids: + self.default_val = None + + custom = fields.Boolean(help="Allow custom values for this attribute?") + required = fields.Boolean(help="Is this attribute required?") + multi = fields.Boolean( + help="Allow selection of multiple values for this attribute?", + ) + default_val = fields.Many2one(comodel_name="product.attribute.value") + + sequence = fields.Integer(default=10) + + @api.constrains("value_ids", "default_val") + def _check_default_values(self): + """default value should not be outside of the + values selected in attribute line""" + for line in self.filtered(lambda line: line.default_val): + if line.default_val not in line.value_ids: + raise ValidationError( + _( + "Default values for each attribute line must exist in " + "the attribute values (%(attr_name)s: %(default_val)s)" + ) + % ( + { + "attr_name": line.attribute_id.name, + "default_val": line.default_val.name, + } + ) + ) + + @api.constrains("active", "value_ids", "attribute_id") + def _check_valid_values(self): + """Overwrite to save attribute line without + values when custom is true""" + for ptal in self: + # Customization + if ptal.active and not ptal.value_ids and not ptal.custom: + # Old code + # if ptal.active and not ptal.value_ids: + # Customization End + raise ValidationError( + _( + "The attribute %(attr)s must have at least one value for " + "the product %(product)s." + ) + % ( + { + "attr": ptal.attribute_id.display_name, + "product": ptal.product_tmpl_id.display_name, + } + ) + ) + for pav in ptal.value_ids: + if pav.attribute_id != ptal.attribute_id: + raise ValidationError( + _( + "On the product %(product)s you cannot associate the " + "value %(value)s with the attribute %(attr)s because they " + "do not match." + ) + % ( + { + "product": ptal.product_tmpl_id.display_name, + "value": pav.display_name, + "attr": ptal.attribute_id.display_name, + } + ) + ) + return True + + +class ProductAttributeValue(models.Model): + _inherit = "product.attribute.value" + + def copy(self, default=None): + """Add ' (Copy)' in name to prevent attribute + having same name while copying""" + if not default: + default = {} + default.update({"name": self.name + " (copy)"}) + product = super().copy(default) + return product + + active = fields.Boolean( + default=True, + help="By unchecking the active field you can " + "disable a attribute value without deleting it", + ) + product_id = fields.Many2one(comodel_name="product.product") + image = fields.Binary( + attachment=True, + help="Attribute value image (Display on website for radio buttons)", + ) + + @api.model + def get_attribute_value_extra_prices( + self, product_tmpl_id, pt_attr_value_ids, pricelist=None + ): + extra_prices = {} + if not pricelist: + pricelist = self.env.user.partner_id.property_product_pricelist + + related_product_av_ids = self.env["product.attribute.value"].search( + [("id", "in", pt_attr_value_ids.ids), ("product_id", "!=", False)] + ) + extra_prices = { + av.id: av.product_id.with_context( + pricelist=pricelist.id + )._get_contextual_price() + for av in related_product_av_ids + } + remaining_av_ids = pt_attr_value_ids - related_product_av_ids + pe_lines = self.env["product.template.attribute.value"].search( + [ + ("product_attribute_value_id", "in", remaining_av_ids.ids), + ("product_tmpl_id", "=", product_tmpl_id), + ] + ) + for line in pe_lines: + attr_val_id = line.product_attribute_value_id + if attr_val_id.id not in extra_prices: + extra_prices[attr_val_id.id] = 0 + extra_prices[attr_val_id.id] += line.price_extra + return extra_prices + + def _compute_display_name(self): + super()._compute_display_name() + if self._context.get("show_price_extra"): + product_template_id = self.env.context.get("active_id", False) + price_precision = self.env["decimal.precision"].precision_get( + "Product Price" + ) + for rec in self: + extra_prices = rec.get_attribute_value_extra_prices( + product_tmpl_id=product_template_id, pt_attr_value_ids=rec + ) + price_extra = extra_prices.get(rec.id) + if price_extra: + name = ("{} ( +{} )").format( + rec.name, + ("{0:,.%sf}" % (price_precision)).format(price_extra), + ) + rec.display_name = name or rec.display_name + + @api.model + def web_search_read( + self, domain, specification, offset=0, limit=None, order=None, count_limit=None + ): + """Use name_search as a domain restriction for the frontend to show + only values set on the product template taking all the configuration + restrictions into account. + + TODO: This only works when activating the selection not when typing + """ + if self.env.context.get("wizard_id"): + wiz_id = self.env["product.configurator"].browse( + self.env.context.get("wizard_id") + ) + if ( + wiz_id.domain_attr_ids + and self.env.context.get("field_name") == wiz_id.dyn_field_value + ): + if self.env.context.get("is_m2m"): + if len(domain) > 2: + vals1 = domain[-1] + vals2 = domain[1] + if vals2 and vals1: + vals = list(set(vals2[2]) - set(vals1[2])) + domain = [("id", "in", vals)] + else: + domain = [("id", "in", wiz_id.domain_attr_ids.ids)] + + elif wiz_id.domain_attr_2_ids and ( + self.env.context.get("field_name") == wiz_id.dyn_field_2_value + or not wiz_id.dyn_field_2_value + ): + domain = [("id", "in", wiz_id.domain_attr_2_ids.ids)] + + product_tmpl_id = self.env.context.get("_cfg_product_tmpl_id") + if product_tmpl_id: + # TODO: Avoiding browse here could be a good performance enhancer + product_tmpl = self.env["product.template"].browse(product_tmpl_id) + tmpl_vals = product_tmpl.attribute_line_ids.mapped("value_ids") + attr_restrict_ids = [] + preset_val_ids = [] + new_args = [] + for arg in domain: + # Restrict values only to value_ids set on product_template + if arg[0] == "id" and arg[1] == "not in": + preset_val_ids = arg[2] + # TODO: Check if all values are available for configuration + else: + new_args.append(arg) + val_ids = set(tmpl_vals.ids) + if preset_val_ids: + val_ids -= set(arg[2]) + val_ids = self.env["product.config.session"].values_available( + val_ids, preset_val_ids, product_tmpl_id=product_tmpl_id + ) + new_args.append(("id", "in", val_ids)) + mono_tmpl_lines = product_tmpl.attribute_line_ids.filtered( + lambda line: not line.multi + ) + for line in mono_tmpl_lines: + line_val_ids = set(line.mapped("value_ids").ids) + if line_val_ids & set(preset_val_ids): + attr_restrict_ids.append(line.attribute_id.id) + if attr_restrict_ids: + new_args.append(("attribute_id", "not in", attr_restrict_ids)) + domain = new_args + res = super().web_search_read( + domain, + specification, + offset=offset, + limit=limit, + order=order, + count_limit=count_limit, + ) + return res + + @api.model + def name_search(self, name="", args=None, operator="ilike", limit=100): + """Use name_search as a domain restriction for the frontend to show + only values set on the product template taking all the configuration + restrictions into account. + + TODO: This only works when activating the selection not when typing + """ + if self.env.context.get("wizard_id"): + wiz_id = self.env[ + self.env.context.get("active_model", "product.configurator") + ].browse(self.env.context.get("wizard_id")) + if ( + wiz_id.domain_attr_ids + and self.env.context.get("field_name") == wiz_id.dyn_field_value + ): + if self.env.context.get("is_m2m"): + if len(args) > 2: + vals1 = args[-1] + vals2 = args[1] + if vals2 and vals1: + vals = list(set(vals2[2]) - set(vals1[2])) + args = [("id", "in", vals)] + else: + args = [("id", "in", wiz_id.domain_attr_ids.ids)] + + elif wiz_id.domain_attr_2_ids and ( + self.env.context.get("field_name") == wiz_id.dyn_field_2_value + or not wiz_id.dyn_field_2_value + ): + if len(args) > 2: + vals1 = args[-1] + vals2 = args[1] + if vals2 and vals1: + vals = list(set(vals2[2]) - set(vals1[2])) + args = [("id", "in", vals)] + else: + args = [("id", "in", wiz_id.domain_attr_2_ids.ids)] + + else: + field_prefix = wiz_id._prefixes.get("field_prefix") + dyn_restricted_attrs_dicts = ( + wiz_id.dyn_restricted_attrs_dicts + and ast.literal_eval(wiz_id.dyn_restricted_attrs_dicts) + or {} + ) + if ( + field_prefix + and self.env.context.get("field_name") + and wiz_id.domain_attr_ids + and wiz_id.domain_attr_2_ids + and not self.env.context.get("is_m2m", False) + ): + attrs_split = self.env.context.get("field_name").split(field_prefix) + if ( + attrs_split + and len(attrs_split) == 2 + and args + and not args[0][2] + ): + attribute_id = int(attrs_split[1]) + domain_attr_ids = wiz_id.domain_attr_ids.filtered( + lambda l: l.attribute_id.id == attribute_id + ) + domain_attr_2_ids = wiz_id.domain_attr_2_ids.filtered( + lambda l: l.attribute_id.id == attribute_id + ) + if domain_attr_ids.ids: + args[0][2] = domain_attr_ids.ids + if domain_attr_2_ids.ids: + args[0][2] = domain_attr_2_ids.ids + elif dyn_restricted_attrs_dicts.get( + self.env.context.get("field_name"), False + ): + args = [ + ( + "id", + "in", + dyn_restricted_attrs_dicts.get( + self.env.context.get("field_name") + ), + ) + ] + + product_tmpl_id = self.env.context.get("_cfg_product_tmpl_id") + if product_tmpl_id: + # TODO: Avoiding browse here could be a good performance enhancer + product_tmpl = self.env["product.template"].browse(product_tmpl_id) + tmpl_vals = product_tmpl.attribute_line_ids.mapped("value_ids") + attr_restrict_ids = [] + preset_val_ids = [] + new_args = [] + for arg in args: + # Restrict values only to value_ids set on product_template + if arg[0] == "id" and arg[1] == "not in": + preset_val_ids = arg[2] + # TODO: Check if all values are available for configuration + else: + new_args.append(arg) + val_ids = set(tmpl_vals.ids) + if preset_val_ids: + val_ids -= set(arg[2]) + val_ids = self.env["product.config.session"].values_available( + val_ids, preset_val_ids, product_tmpl_id=product_tmpl_id + ) + new_args.append(("id", "in", val_ids)) + mono_tmpl_lines = product_tmpl.attribute_line_ids.filtered( + lambda line: not line.multi + ) + for line in mono_tmpl_lines: + line_val_ids = set(line.mapped("value_ids").ids) + if line_val_ids & set(preset_val_ids): + attr_restrict_ids.append(line.attribute_id.id) + if attr_restrict_ids: + new_args.append(("attribute_id", "not in", attr_restrict_ids)) + args = new_args + res = super().name_search(name=name, args=args, operator=operator, limit=limit) + return res + + +class ProductAttributePrice(models.Model): + _inherit = "product.template.attribute.value" + # Leverage product.template.attribute.value to compute the extra weight + # each attribute adds + + weight_extra = fields.Float(string="Attribute Weight Extra", digits="Stock Weight") + + +class ProductAttributeValueLine(models.Model): + _name = "product.attribute.value.line" + _description = "Product Attribute Value Line" + _order = "sequence" + + sequence = fields.Integer(default=10) + product_tmpl_id = fields.Many2one( + comodel_name="product.template", + string="Product Template", + ondelete="cascade", + required=True, + ) + value_id = fields.Many2one( + comodel_name="product.attribute.value", + required=True, + string="Attribute Value", + ) + attribute_id = fields.Many2one( + comodel_name="product.attribute", related="value_id.attribute_id" + ) + value_ids = fields.Many2many( + comodel_name="product.attribute.value", + relation="product_attribute_value_product_attribute_value_line_rel", + column1="product_attribute_value_line_id", + column2="product_attribute_value_id", + string="Values Configuration", + ) + product_value_ids = fields.Many2many( + comodel_name="product.attribute.value", + relation="product_attr_values_attr_values_rel", + column1="product_val_id", + column2="attr_val_id", + compute="_compute_get_value_id", + store=True, + ) + + @api.depends( + "product_tmpl_id", + "product_tmpl_id.attribute_line_ids", + "product_tmpl_id.attribute_line_ids.value_ids", + ) + def _compute_get_value_id(self): + for attr_val_line in self: + template = attr_val_line.product_tmpl_id + value_list = template.attribute_line_ids.mapped("value_ids") + attr_val_line.product_value_ids = [(6, 0, value_list.ids)] + + @api.constrains("value_ids") + def _validate_configuration(self): + """Ensure that the passed configuration in value_ids is a valid""" + cfg_session_obj = self.env["product.config.session"] + for attr_val_line in self: + value_ids = attr_val_line.value_ids.ids + value_ids.append(attr_val_line.value_id.id) + valid = cfg_session_obj.validate_configuration( + value_ids=value_ids, + product_tmpl_id=attr_val_line.product_tmpl_id.id, + final=False, + ) + if not valid: + raise ValidationError( + _( + "Values provided to the attribute value line are " + "incompatible with the current rules" + ) + ) diff --git a/product_configurator/models/product_config.py b/product_configurator/models/product_config.py new file mode 100644 index 0000000000..90c748d826 --- /dev/null +++ b/product_configurator/models/product_config.py @@ -0,0 +1,1693 @@ +import logging +from ast import literal_eval + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.tools.misc import formatLang + +_logger = logging.getLogger(__name__) + + +class ProductConfigDomain(models.Model): + _name = "product.config.domain" + _description = "Domain for Config Restrictions" + + @api.depends("implied_ids") + def _get_trans_implied(self): + """Computes the transitive closure of relation implied_ids""" + + def linearize(domains): + trans_domains = domains + for domain in domains: + implied_domains = domain.implied_ids - domain + if implied_domains: + trans_domains |= linearize(implied_domains) + return trans_domains + + for domain in self: + domain.trans_implied_ids = linearize(domain) + + def compute_domain(self): + """Returns a list of domains defined on a + product.config.domain_line_ids and all implied_ids""" + # TODO: Enable the usage of OR operators between implied_ids + # TODO: Add implied_ids sequence field to enforce order of operations + # TODO: Prevent circular dependencies + computed_domain = [] + for domain in self: + lines = domain.trans_implied_ids.mapped("domain_line_ids").sorted() + if not lines: + continue + for line in lines[:-1]: + if line.operator == "or": + computed_domain.append("|") + computed_domain.append( + (line.attribute_id.id, line.condition, line.value_ids.ids) + ) + # ensure 2 operands follow the last operator + computed_domain.append( + ( + lines[-1].attribute_id.id, + lines[-1].condition, + lines[-1].value_ids.ids, + ) + ) + return computed_domain + + name = fields.Char(required=True) + domain_line_ids = fields.One2many( + comodel_name="product.config.domain.line", + inverse_name="domain_id", + string="Restrictions", + required=True, + copy=True, + ) + implied_ids = fields.Many2many( + comodel_name="product.config.domain", + relation="product_config_domain_implied_rel", + string="Inherited", + column1="domain_id", + column2="parent_id", + ) + trans_implied_ids = fields.Many2many( + comodel_name="product.config.domain", + compute=_get_trans_implied, + column1="domain_id", + column2="parent_id", + string="Transitively inherits", + ) + + +class ProductConfigDomainLine(models.Model): + _name = "product.config.domain.line" + _order = "sequence" + _description = "Domain Line for Config Restrictions" + + def _get_domain_conditions(self): + operators = [("in", "In"), ("not in", "Not In")] + + return operators + + def _get_domain_operators(self): + andor = [("and", "And"), ("or", "Or")] + + return andor + + @api.depends("attribute_id") + def _compute_template_attribute_value_ids(self): + for domain in self: + domain.template_attribute_value_ids = ( + domain._get_allowed_attribute_value_ids() + ) + + def _compute_attribute_id_domain(self): + if "product_attribute_ids" in self.env.context: + return [("id", "in", self.env.context["product_attribute_ids"][0][2])] + return [] + + def _get_allowed_attribute_value_ids(self): + self.ensure_one() + product_template = self.env["product.template"] + if self.env.context.get("product_tmpl_id"): + product_template = product_template.browse( + self.env.context.get("product_tmpl_id") + ) + template_lines = product_template.attribute_line_ids + attribute_values = self.attribute_id.value_ids + return ( + product_template + and (template_lines.mapped("value_ids") & attribute_values) + or attribute_values + ) + + template_attribute_value_ids = fields.Many2many( + comodel_name="product.attribute.value", + string="Template Attribute Values", + compute="_compute_template_attribute_value_ids", + ) + attribute_id = fields.Many2one( + comodel_name="product.attribute", + string="Attribute", + required=True, + domain=lambda self: self._compute_attribute_id_domain(), + ) + domain_id = fields.Many2one( + comodel_name="product.config.domain", required=True, string="Rule" + ) + condition = fields.Selection(selection=_get_domain_conditions, required=True) + value_ids = fields.Many2many( + comodel_name="product.attribute.value", + relation="product_config_domain_line_attr_rel", + column1="line_id", + column2="attribute_id", + string="Values", + required=True, + ) + operator = fields.Selection( + selection=_get_domain_operators, + string="Operators", + default="and", + required=True, + ) + sequence = fields.Integer( + default=1, + help="Set the order of operations for evaluation domain lines", + ) + + +class ProductConfigLine(models.Model): + _name = "product.config.line" + _description = "Product Config Restrictions" + _order = "product_tmpl_id, sequence, id" + + # TODO: Prevent config lines having dependencies that are not set in other + # config lines + # TODO: Prevent circular depdencies: Length -> Color, Color -> Length + + @api.onchange("attribute_line_id") + def onchange_attribute(self): + self.value_ids = False + self.domain_id = False + + @api.depends( + "product_tmpl_id", + "attribute_line_id", + "product_tmpl_id.attribute_line_ids", + "product_tmpl_id.config_line_ids", + ) + def _compute_template_attribute_ids(self): + for config_line in self: + product_template = config_line.product_tmpl_id + attribute_line_ids = product_template.attribute_line_ids + config_line.template_attribute_ids = attribute_line_ids.mapped( + "attribute_id" + ) + + template_attribute_ids = fields.Many2many( + comodel_name="product.attribute", + string="Template Attributes", + compute="_compute_template_attribute_ids", + ) + product_tmpl_id = fields.Many2one( + comodel_name="product.template", + string="Product Template", + ondelete="cascade", + required=True, + ) + attribute_line_id = fields.Many2one( + comodel_name="product.template.attribute.line", + string="Attribute Line", + ondelete="cascade", + required=True, + ) + # TODO: Find a more elegant way to restrict the value_ids + attr_line_val_ids = fields.Many2many( + comodel_name="product.attribute.value", + related="attribute_line_id.value_ids", + string="Attribute Line Values", + ) + value_ids = fields.Many2many( + comodel_name="product.attribute.value", + relation="cfg_line_attr_val_id_rel", + column1="cfg_line_id", + column2="attr_val_id", + string="Values", + ) + domain_id = fields.Many2one( + comodel_name="product.config.domain", + required=True, + string="Restrictions", + ) + sequence = fields.Integer(default=10) + + @api.constrains("value_ids") + def check_value_attributes(self): + """Values selected in config lines must belong to the + attribute exist on linked attribute line""" + for line in self: + value_attributes = line.value_ids.mapped("attribute_id") + if value_attributes != line.attribute_line_id.attribute_id: + raise ValidationError( + _( + "Values must belong to the attribute of the " + "corresponding attribute_line set on the " + "configuration line" + ) + ) + + +class ProductConfigImage(models.Model): + _name = "product.config.image" + _inherit = ["image.mixin"] + _description = "Product Config Image" + _order = "sequence" + + name = fields.Char(required=True, translate=True) + product_tmpl_id = fields.Many2one( + comodel_name="product.template", + string="Product", + ondelete="cascade", + required=True, + ) + sequence = fields.Integer(default=10) + value_ids = fields.Many2many( + comodel_name="product.attribute.value", string="Configuration" + ) + + @api.constrains("value_ids") + def _check_value_ids(self): + """Check combination of values is possible according to given + restrictions on linked product template""" + cfg_session_obj = self.env["product.config.session"] + for cfg_img in self: + try: + cfg_session_obj.validate_configuration( + value_ids=cfg_img.value_ids.ids, + product_tmpl_id=cfg_img.product_tmpl_id.id, + final=False, + ) + except ValidationError as exc: + raise ValidationError( + _( + "Values entered for line '%s' generate " + "a incompatible configuration" + ) + % cfg_img.name + ) from exc + + +class ProductConfigStep(models.Model): + _name = "product.config.step" + _description = "Product Config Steps" + + # TODO: Prevent values which have dependencies to be set in a + # step with higher sequence than the dependency + + name = fields.Char(required=True, translate=True) + + +class ProductConfigStepLine(models.Model): + _name = "product.config.step.line" + _description = "Product Config Step Lines" + _order = "sequence, config_step_id, id" + + name = fields.Char(related="config_step_id.name") + config_step_id = fields.Many2one( + comodel_name="product.config.step", + string="Configuration Step", + required=True, + ) + attribute_line_ids = fields.Many2many( + comodel_name="product.template.attribute.line", + relation="config_step_line_attr_id_rel", + column1="cfg_line_id", + column2="attr_id", + string="Attribute Lines", + ) + product_tmpl_id = fields.Many2one( + comodel_name="product.template", + string="Product Template", + ondelete="cascade", + required=True, + ) + sequence = fields.Integer(default=10) + + @api.constrains("config_step_id") + def _check_config_step(self): + """Prevent to add same step more than once on same product template""" + for config_step in self: + cfg_step_lines = config_step.product_tmpl_id.config_step_line_ids + cfg_steps = cfg_step_lines.filtered( + lambda line: line != config_step + ).mapped("config_step_id") + if config_step.config_step_id in cfg_steps: + raise ValidationError( + _("Cannot have a configuration step defined twice.") + ) + + +class ProductConfigSession(models.Model): + _name = "product.config.session" + _description = "Product Config Session" + + @api.depends( + "value_ids", + "product_tmpl_id.list_price", + "product_tmpl_id.attribute_line_ids", + "product_tmpl_id.attribute_line_ids.value_ids", + "product_tmpl_id.attribute_line_ids.product_template_value_ids", + "product_tmpl_id.attribute_line_ids." "product_template_value_ids.price_extra", + ) + def _compute_cfg_price(self): + for session in self: + if session.product_tmpl_id: + price = session.with_company(session.company_id).get_cfg_price() + else: + price = 0.00 + session.price = price + + def get_custom_value_id(self): + """Return record set of attribute value 'custom'""" + custom_ext_id = "product_configurator.custom_attribute_value" + custom_val_id = self.env.ref(custom_ext_id) + return custom_val_id + + @api.model + def _get_custom_vals_dict(self): + """Retrieve session custom values as a dictionary of the form + {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 + else: + custom_vals[val.attribute_id.id] = val.value + return custom_vals + + def _compute_config_step_name(self): + """Get the config.step.line name using the string stored in config_step + field of the session""" + cfg_step_line_obj = self.env["product.config.step.line"] + cfg_session_step_lines = self.mapped("config_step") + cfg_step_line_ids = set() + for step in cfg_session_step_lines: + try: + cfg_step_line_ids.add(int(step)) + except ValueError: + _logger.debug("Step from session not valid") + cfg_step_lines = cfg_step_line_obj.browse(cfg_step_line_ids) + for session in self: + try: + config_step = int(session.config_step) + config_step_line = cfg_step_lines.filtered( + lambda x: x.id == config_step + ) + session.config_step_name = config_step_line.name + except Exception: + _logger.debug("Invalid session data ignored") + if not session.config_step_name: + session.config_step_name = session.config_step + + @api.model + def get_cfg_weight(self, value_ids=None, custom_vals=None): + """Computes the weight of the configured product based on the + configuration passed in via value_ids and custom_values + + :param value_ids: list of attribute value_ids + :param custom_vals: dictionary of custom attribute values + :returns: final configuration weight""" + + if value_ids is None: + value_ids = self.value_ids.ids + + if custom_vals is None: + custom_vals = {} + + product_tmpl = self.product_tmpl_id + + self = self.with_context(active_id=product_tmpl.id) + + value_ids = self.flatten_val_ids(value_ids) + + weight_extra = 0.0 + product_attr_val_obj = self.env["product.template.attribute.value"] + product_tmpl_attr_values = product_attr_val_obj.search( + [ + ("product_tmpl_id", "in", product_tmpl.ids), + ("product_attribute_value_id", "in", value_ids), + ] + ) + for product_tmpl_attr_val in product_tmpl_attr_values: + weight_extra += product_tmpl_attr_val.weight_extra + + return product_tmpl.weight + weight_extra + + @api.depends( + "value_ids", + "product_tmpl_id", + "product_tmpl_id.attribute_line_ids", + "product_tmpl_id.attribute_line_ids.value_ids", + "product_tmpl_id.attribute_line_ids.product_template_value_ids", + "product_tmpl_id.attribute_line_ids.product_template_value_ids" ".weight_extra", + ) + def _compute_cfg_weight(self): + for cfg_session in self: + cfg_session.weight = cfg_session.get_cfg_weight() + + def _compute_currency_id(self): + main_company = self.env["res.company"]._get_main_company() + for session in self: + template = session.product_tmpl_id + session.currency_id = ( + template.company_id.sudo().currency_id.id or main_company.currency_id.id + ) + + name = fields.Char(string="Configuration Session Number", readonly=True) + config_step = fields.Char(string="Configuration Step ID") + config_step_name = fields.Char( + compute="_compute_config_step_name", string="Configuration Step" + ) + product_id = fields.Many2one( + comodel_name="product.product", + name="Configured Variant", + ondelete="cascade", + ) + product_tmpl_id = fields.Many2one( + comodel_name="product.template", + domain=[("config_ok", "=", True)], + string="Configurable Template", + required=True, + ) + value_ids = fields.Many2many( + comodel_name="product.attribute.value", + relation="product_config_session_attr_values_rel", + column1="cfg_session_id", + column2="attr_val_id", + ) + user_id = fields.Many2one(comodel_name="res.users", required=True, string="User") + custom_value_ids = fields.One2many( + comodel_name="product.config.session.custom.value", + inverse_name="cfg_session_id", + string="Custom Values", + ) + price = fields.Float( + compute="_compute_cfg_price", + store=True, + digits="Product Price", + ) + currency_id = fields.Many2one( + comodel_name="res.currency", + string="Currency", + compute="_compute_currency_id", + ) + state = fields.Selection( + required=True, + selection=[("draft", "Draft"), ("done", "Done")], + default="draft", + ) + weight = fields.Float(compute="_compute_cfg_weight", digits="Stock Weight") + # Product preset + product_preset_id = fields.Many2one( + comodel_name="product.product", + string="Preset", + domain="[('product_tmpl_id', '=', product_tmpl_id),\ + ('config_preset_ok', '=', True)]", + ) + company_id = fields.Many2one( + "res.company", string="Company", default=lambda self: self.env.company + ) + + def action_confirm(self, product_id=None): + for session in self: + if product_id is None: + product_id = session.create_get_variant() + session.write({"state": "done", "product_id": product_id.id}) + return True + + @api.constrains("state") + def _check_product_id(self): + for session in self.filtered(lambda s: s.state == "done"): + if not session.product_id: + raise ValidationError( + _("Finished configuration session must have a " "product_id linked") + ) + + def update_session_configuration_value(self, vals, product_tmpl_id=None): + """Update value of configuration in current session + + :param: vals: Dictionary of fields(of configution wizard) and values + :param: product_tmpl_id: record set of preoduct template + :return: True/False + """ + self.ensure_one() + if not product_tmpl_id: + product_tmpl_id = self.product_tmpl_id + + product_configurator_obj = self.env["product.configurator"] + field_prefix = product_configurator_obj._prefixes.get("field_prefix") + custom_field_prefix = product_configurator_obj._prefixes.get( + "custom_field_prefix" + ) + + custom_val = self.get_custom_value_id() + + attr_val_dict = {} + custom_val_dict = {} + for attr_line in product_tmpl_id.attribute_line_ids: + attr_id = attr_line.attribute_id.id + field_name = field_prefix + str(attr_id) + custom_field_name = custom_field_prefix + str(attr_id) + if field_name not in vals and custom_field_name not in vals: + continue + + # Add attribute values from the client except custom attribute + # If a custom value is being written, but field name is not in + # the write dictionary, then it must be a custom value! + if vals.get(field_name, custom_val.id) != custom_val.id: + if attr_line.multi and isinstance(vals[field_name], list): + if not vals[field_name]: + field_val = None + else: + value_ids = self.value_ids.filtered( + lambda value: value.attribute_id.id + == attr_line.attribute_id.id + ) + field_val = value_ids and value_ids.ids or [] + for field_vals in vals[field_name]: + if field_vals and field_vals[0] == 6: + field_val += field_vals[2] or [] + elif field_vals and field_vals[0] == 4: + field_val.append(field_vals[1]) + elif ( + field_vals + and field_vals[0] == 3 + and field_vals[1] in field_val + ): + field_val.remove(field_vals[1]) + # field_val = [ + # i[1] for i in vals[field_name] if vals[field_name][0] + # ] or vals[field_name][0][1] + elif not attr_line.multi and isinstance(vals[field_name], int): + field_val = vals[field_name] + else: + raise UserError( + _("An error occurred while parsing value for attribute %s") + % attr_line.attribute_id.name + ) + attr_val_dict.update({attr_id: field_val}) + # Ensure there is no custom value stored if we have switched + # from custom value to selected attribute value. + if attr_line.custom: + custom_val_dict.update({attr_id: False}) + elif attr_line.custom: + val = vals.get(custom_field_name, False) + if attr_line.attribute_id.custom_type == "binary": + # TODO: Add widget that enables multiple file uploads + val = [{"name": "custom", "datas": vals[custom_field_name]}] + custom_val_dict.update({attr_id: val}) + # Ensure there is no standard value stored if we have switched + # from selected value to custom value. + attr_val_dict.update({attr_id: False}) + + self.update_config(attr_val_dict, custom_val_dict) + + def update_config(self, attr_val_dict=None, custom_val_dict=None): + """Update the session object with the given value_ids and custom values. + + Use this method instead of write in order to prevent incompatible + configurations as this removed duplicate values for the same attribute. + + :param attr_val_dict: Dictionary of the form { + int (attribute_id): attribute_value_id OR [attribute_value_ids] + } + + :custom_val_dict: Dictionary of the form { + int (attribute_id): { + 'value': 'custom val', + OR + 'attachment_ids': { + [{ + 'name': 'attachment name', + 'datas': base64_encoded_string + }] + } + } + } + + """ + if attr_val_dict is None: + attr_val_dict = {} + if custom_val_dict is None: + custom_val_dict = {} + update_vals = {} + + value_ids = self.value_ids.ids + for attr_id, vals in attr_val_dict.items(): + attr_val_ids = self.value_ids.filtered( + lambda x: x.attribute_id.id == int(attr_id) + ).ids + # Remove all values for this attribute and add vals from dict + value_ids = list(set(value_ids) - set(attr_val_ids)) + if not vals: + continue + if isinstance(vals, list): + value_ids += vals + elif isinstance(vals, int): + value_ids.append(vals) + + if value_ids != self.value_ids.ids: + update_vals.update({"value_ids": [(6, 0, value_ids)]}) + + # Remove all custom values included in the custom_vals dict + self.custom_value_ids.filtered( + lambda x: x.attribute_id.id in custom_val_dict.keys() + ).unlink() + + if custom_val_dict: + binary_field_ids = ( + self.env["product.attribute"] + .search( + [ + ("id", "in", list(custom_val_dict.keys())), + ("custom_type", "=", "binary"), + ] + ) + .ids + ) + else: + binary_field_ids = [] + + for attr_id, vals in custom_val_dict.items(): + if not vals: + continue + + if "custom_value_ids" not in update_vals: + update_vals["custom_value_ids"] = [] + + custom_vals = {"attribute_id": attr_id} + + if attr_id in binary_field_ids: + attachments = [ + ( + 0, + 0, + {"name": val.get("name"), "datas": val.get("datas")}, + ) + for val in vals + ] + custom_vals.update({"attachment_ids": attachments}) + else: + custom_vals.update({"value": vals}) + + update_vals["custom_value_ids"].append((0, 0, custom_vals)) + self.write(update_vals) + + def write(self, vals): + """Validate configuration when writing new values to session""" + # TODO: Issue warning when writing to value_ids or custom_val_ids + res = super().write(vals) + if not self.product_tmpl_id: + return res + value_ids = self.value_ids.ids + avail_val_ids = self.values_available(value_ids) + if set(value_ids) - set(avail_val_ids): + self.value_ids = [(6, 0, avail_val_ids)] + try: + self.validate_configuration(final=False) + except ValidationError as exc: + raise ValidationError(_(f"{exc}")) + except Exception as exc: + raise ValidationError(_("Invalid Configuration")) from exc + return res + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + vals["name"] = self.env["ir.sequence"].next_by_code( + "product.config.session" + ) or _("New") + product_tmpl = ( + self.env["product.template"] + .browse(vals.get("product_tmpl_id")) + .exists() + ) + if product_tmpl: + default_val_ids = ( + product_tmpl.attribute_line_ids.filtered( + lambda line: line.default_val + ) + .mapped("default_val") + .ids + ) + value_ids = vals.get("value_ids") + if value_ids: + default_val_ids += value_ids[0][2] + try: + self.validate_configuration( + value_ids=default_val_ids, + final=False, + product_tmpl_id=product_tmpl.id, + ) + # TODO: Remove if cond when PR with + # raise error on github is merged + except ValidationError as exc: + raise ValidationError(_("%s") % exc.name) from exc + except Exception as exc: + raise ValidationError( + _( + "Default values provided generate an invalid " + "configuration" + ) + ) from exc + vals.update({"value_ids": [(6, 0, default_val_ids)]}) + return super().create(vals_list) + + def create_get_variant(self, value_ids=None, custom_vals=None): + """Creates a new product variant with the attributes passed + via value_ids and custom_values or retrieves an existing + one based on search result + + :param value_ids: list of product.attribute.values ids + :param custom_vals: dict {product.attribute.id: custom_value} + + :returns: new/existing product.product recordset + + """ + if self.product_tmpl_id.config_ok: + self.validate_configuration() + if value_ids is None: + value_ids = self.value_ids.ids + + if custom_vals is None: + custom_vals = self._get_custom_vals_dict() + + try: + self.validate_configuration() + except ValidationError as exc: + raise ValidationError(_("%s") % exc.name) from exc + except Exception as exc: + raise ValidationError(_("Invalid Configuration")) from exc + + duplicates = self.search_variant( + value_ids=value_ids, product_tmpl_id=self.product_tmpl_id + ) + if duplicates: + return duplicates[:1] + + vals = self.get_variant_vals(value_ids, custom_vals) + product_obj = ( + self.env["product.product"].sudo().with_context(mail_create_nolog=True) + ) + variant = product_obj.sudo().create(vals) + + variant.message_post( + body=_("Product created via configuration wizard"), + author_id=self.env.user.partner_id.id, + ) + + return variant + + def _get_option_values(self, pricelist, value_ids=None): + """Return only attribute values that have products attached with a + price set to them""" + if value_ids is None: + value_ids = self.value_ids.ids + + value_obj = self.env["product.attribute.value"].with_context( + pricelist=pricelist.id + ) + values = ( + value_obj.sudo().browse(value_ids).filtered(lambda x: x.product_id.price) + ) + return values + + def get_components_prices(self, prices, pricelist, value_ids=None): + """Return prices of the components which make up the final + configured variant""" + if value_ids is None: + value_ids = self.value_ids.ids + vals = self._get_option_values(pricelist, value_ids) + for val in vals: + prices["vals"].append( + ( + val.attribute_id.name, + val.product_id.name, + val.product_id.price, + ) + ) + product = val.product_id.with_context(pricelist=pricelist.id) + product_prices = product.taxes_id.sudo().compute_all( + price_unit=product.price, + currency=pricelist.currency_id, + quantity=1, + product=self, + partner=self.env.user.partner_id, + ) + + total_included = product_prices["total_included"] + taxes = total_included - product_prices["total_excluded"] + prices["taxes"] += taxes + prices["total"] += total_included + return prices + + @api.model + def get_cfg_price(self, value_ids=[], custom_vals=None): + """Computes the price of the configured product based on the + configuration passed in via value_ids and custom_values + + :param value_ids: list of attribute value_ids + :param custom_vals: dictionary of custom attribute values + :returns: final configuration price""" + if not value_ids: + value_ids = self.value_ids.ids + + if custom_vals is None: + custom_vals = {} + value_ids = value_ids + self.value_ids.ids + if self.env.context.get("tobe_remove_attr", []): + value_ids = self.flatten_val_ids(value_ids) + value_ids = set(value_ids) - set( + self.env.context.get("tobe_remove_attr", []) + ) + value_ids = list(value_ids) + 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()) + return product_tmpl.list_price + price_extra + + def _get_config_image(self, value_ids=None, custom_vals=None, size=None): + """ + Retreive the image object that most closely resembles the configuration + code sent via value_ids list + + The default image object is the template (self) + :param value_ids: a list representing the ids of attribute values + (usually stored in the user's session) + :param custom_vals: dictionary of custom attribute values + :returns: path to the selected image + """ + # TODO: Also consider custom values for image change + if value_ids is None: + value_ids = self.value_ids.ids + + if custom_vals is None: + custom_vals = self._get_custom_vals_dict() + + img_obj = self.product_tmpl_id + max_matches = 0 + value_ids = self.flatten_val_ids(value_ids) + for line in self.product_tmpl_id.config_image_ids: + matches = len(set(line.value_ids.ids) & set(value_ids)) + if matches > max_matches: + img_obj = line + max_matches = matches + return img_obj + + def get_config_image(self, value_ids=None, custom_vals=None, size=None): + """ + Retreive the image object that most closely resembles the configuration + code sent via value_ids list + For more information check _get_config_image + """ + config_image_id = self._get_config_image( + value_ids=value_ids, custom_vals=custom_vals + ) + return config_image_id.image_1920 + + @api.model + def get_variant_vals(self, value_ids=None, custom_vals=None, **kwargs): + """Hook to alter the values of the product variant before creation + + :param value_ids: list of product.attribute.values ids + :param custom_vals: dict {product.attribute.id: custom_value} + + :returns: dictionary of values to pass to product.create() method + """ + self.ensure_one() + + if value_ids is None: + value_ids = self.value_ids.ids + + if custom_vals is None: + custom_vals = self._get_custom_vals_dict() + + image = self.get_config_image(value_ids) + ptav_ids = self.env["product.template.attribute.value"].search( + [ + ("product_tmpl_id", "=", self.product_tmpl_id.id), + ("product_attribute_value_id", "in", value_ids), + ] + ) + vals = { + "product_tmpl_id": self.product_tmpl_id.id, + "product_template_attribute_value_ids": [(6, 0, ptav_ids.ids)], + "taxes_id": [(6, 0, self.product_tmpl_id.taxes_id.ids)], + "image_1920": image, + } + return vals + + def get_session_search_domain(self, product_tmpl_id, state="draft", parent_id=None): + """Return domain to search session linked to given + product template and current login user""" + domain = [ + ("product_tmpl_id", "=", product_tmpl_id), + ("user_id", "=", self.env.uid), + ("state", "=", state), + ("company_id", "=", self.env.company.id), + ] + if parent_id: + domain.append(("parent_id", "=", parent_id)) + return domain + + def get_session_vals(self, product_tmpl_id, parent_id=None, user_id=None): + """Return the values for creating session""" + if not user_id: + user_id = self.env.user.id + vals = {"product_tmpl_id": product_tmpl_id, "user_id": user_id} + if parent_id: + vals.update(parent_id=parent_id) + return vals + + def get_next_step( + self, + state, + product_tmpl_id=False, + value_ids=False, + custom_value_ids=False, + ): + """Find and return next step if it exists. This usually + implies the next configuration step (if any) defined via the + config_step_line_ids on the product.template. + """ + + if not product_tmpl_id: + product_tmpl_id = self.product_tmpl_id + if value_ids is False: + value_ids = self.value_ids + if custom_value_ids is False: + custom_value_ids = self.custom_value_ids + if not state: + state = self.config_step + + cfg_step_lines = product_tmpl_id.config_step_line_ids + if not cfg_step_lines: + if (value_ids or custom_value_ids) and state != "select": + return False + elif not (value_ids or custom_value_ids) and state != "select": + raise UserError( + _( + "You must select at least one " + "attribute in order to configure a product" + ) + ) + else: + return "configure" + + adjacent_steps = self.get_adjacent_steps() + next_step = adjacent_steps.get("next_step") + open_step_lines = list( + map(lambda x: "%s" % (x), self.get_open_step_lines().ids) + ) + + session_config_step = self.config_step + if ( + session_config_step + and state != session_config_step + and session_config_step in open_step_lines + ): + next_step = self.config_step + else: + next_step = str(next_step.id) if next_step else None + if next_step: + pass + elif not (value_ids or custom_value_ids): + raise UserError( + _( + "You must select at least one " + "attribute in order to configure a product" + ) + ) + else: + return False + return next_step + + # TODO: Should be renamed to get_active_step_line + + @api.model + def get_active_step(self): + """Attempt to return product.config.step.line object that has the id + of the config session step stored as string""" + cfg_step_line_obj = self.env["product.config.step.line"] + + try: + cfg_step_line_id = int(self.config_step) + except ValueError: + cfg_step_line_id = None + + if cfg_step_line_id: + return cfg_step_line_obj.browse(cfg_step_line_id) + return cfg_step_line_obj + + @api.model + def get_open_step_lines(self, value_ids=None): + """ + Returns a recordset of configuration step lines open for access given + the configuration passed through value_ids + + e.g: Field A and B from configuration step 2 depend on Field C + from configuration step 1. Since fields A and B require action from + the previous step, configuration step 2 is deemed closed and redirect + is made for configuration step 1. + + :param value_ids: list of value.ids representing the + current configuration + :returns: recordset of accesible configuration steps + """ + + if value_ids is None: + value_ids = self.value_ids.ids + + open_step_lines = self.env["product.config.step.line"] + + for cfg_line in self.product_tmpl_id.config_step_line_ids: + for attr_line in cfg_line.attribute_line_ids: + available_vals = self.values_available( + attr_line.value_ids.ids, value_ids + ) + # TODO: Refactor when adding restriction to custom values + if available_vals or attr_line.custom: + open_step_lines |= cfg_line + break + + return open_step_lines.sorted() + + @api.model + def get_all_step_lines(self, product_tmpl_id=None): + """ + Returns a recordset of configuration step lines of product_tmpl_id + + :param product_tmpl_id: record-set of product.template + :returns: recordset of all configuration steps + """ + if not product_tmpl_id: + product_tmpl_id = self.product_tmpl_id + + open_step_lines = product_tmpl_id.config_step_line_ids + return open_step_lines.sorted() + + @api.model + def get_adjacent_steps(self, value_ids=None, active_step_line_id=None): + """Returns the previous and next steps given the configuration passed + via value_ids and the active step line passed via cfg_step_line_id.""" + + # If there is no open step return empty dictionary + + if value_ids is None: + value_ids = self.value_ids.ids + + if not active_step_line_id: + active_step_line_id = self.get_active_step().id + + config_step_lines = self.product_tmpl_id.config_step_line_ids + + if not config_step_lines: + return {} + + active_cfg_step_line = config_step_lines.filtered( + lambda line: line.id == active_step_line_id + ) + + open_step_lines = self.get_open_step_lines(value_ids) + + if not active_cfg_step_line: + return {"next_step": open_step_lines[0]} + + nr_steps = len(open_step_lines) + + adjacent_steps = {} + + for i, cfg_step in enumerate(open_step_lines): + if cfg_step == active_cfg_step_line: + adjacent_steps.update( + { + "next_step": None + if i + 1 == nr_steps + else open_step_lines[i + 1], + "previous_step": None if i == 0 else open_step_lines[i - 1], + } + ) + return adjacent_steps + + def check_and_open_incomplete_step(self, value_ids=None, custom_value_ids=None): + """Check and open incomplete step if any + :param value_ids: recordset of product.attribute.value + """ + if value_ids is None: + value_ids = self.value_ids + if custom_value_ids is None: + custom_value_ids = self.custom_value_ids + custom_attr_selected = custom_value_ids.mapped("attribute_id") + open_step_lines = self.get_open_step_lines() + step_to_open = False + for step in open_step_lines: + unset_attr_line = step.attribute_line_ids.filtered( + lambda attr_line: attr_line.required + and not any([value in value_ids for value in attr_line.value_ids]) + and not ( + attr_line.custom and attr_line.attribute_id in custom_attr_selected + ) + ) + check_val_ids = unset_attr_line.mapped("value_ids") + avail_val_ids = self.values_available( + check_val_ids.ids, + value_ids.ids, + product_tmpl_id=self.product_tmpl_id, + ) + if unset_attr_line and avail_val_ids: + step_to_open = step + break + if step_to_open: + return "%s" % (step_to_open.id) + return False + + @api.model + def get_variant_search_domain(self, product_tmpl_id, value_ids=None): + """Method called by search_variant used to search duplicates in the + database""" + + if value_ids is None: + value_ids = self.value_ids.ids + + domain = [ + ("product_tmpl_id", "=", product_tmpl_id.id), + ("config_ok", "=", True), + ] + pta_value_ids = self.env["product.template.attribute.value"].search( + [ + ("product_tmpl_id", "=", product_tmpl_id.id), + ("product_attribute_value_id", "in", value_ids), + ] + ) + for value_id in pta_value_ids: + domain.append(("product_template_attribute_value_ids", "=", value_id.id)) + return domain + + def validate_domains_against_sels(self, domains, value_ids=None, custom_vals=None): + if custom_vals is None: + custom_vals = self._get_custom_vals_dict() + + if value_ids is None: + value_ids = self.value_ids.ids + + # process domains as shown in this wikipedia pseudocode: + # https://en.wikipedia.org/wiki/Polish_notation#Order_of_operations + stack = [] + for domain in reversed(domains): + if type(domain) == tuple: + # evaluate operand and push to stack + if domain[1] == "in": + if not set(domain[2]) & set(value_ids): + stack.append(False) + continue + else: + if set(domain[2]) & set(value_ids): + stack.append(False) + continue + stack.append(True) + else: + # evaluate operator and previous 2 operands + # compute_domain() only inserts 'or' operators + # compute_domain() enforces 2 operands per operator + operand1 = stack.pop() + operand2 = stack.pop() + stack.append(operand1 or operand2) + + # 'and' operator is implied for remaining stack elements + avail = True + while stack: + avail &= stack.pop() + return avail + + @api.model + def values_available( + self, + check_val_ids=None, + value_ids=None, + custom_vals=None, + product_tmpl_id=None, + ): + """Determines whether the attr_values from the product_template + are available for selection given the configuration ids and the + dependencies set on the product template + + :param check_val_ids: list of attribute value ids to check for + availability + :param value_ids: list of attribute value ids + :param custom_vals: custom values dict {attr_id: custom_val} + + :returns: list of available attribute values + """ + if check_val_ids is None: + check_val_ids = self.value_ids.ids + elif check_val_ids: + check_val_ids = check_val_ids.copy() + if not self.product_tmpl_id: + product_tmpl = self.env["product.template"].browse(product_tmpl_id) + else: + product_tmpl = self.product_tmpl_id + + product_tmpl.ensure_one() + + if value_ids is None: + value_ids = self.value_ids.ids + elif value_ids: + value_ids = value_ids.copy() + + if custom_vals is None: + custom_vals = self._get_custom_vals_dict() + + avail_val_ids = [] + for attr_val_id in check_val_ids: + config_lines = product_tmpl.config_line_ids.filtered( + lambda line: attr_val_id in line.value_ids.ids + ) + domains = config_lines.mapped("domain_id").compute_domain() + avail = self.validate_domains_against_sels(domains, value_ids, custom_vals) + if avail: + avail_val_ids.append(attr_val_id) + elif attr_val_id in value_ids: + value_ids.remove(attr_val_id) + + return avail_val_ids + + @api.model + def get_extra_attribute_line_ids(self, product_template_id): + """Retrieve attribute lines defined on the product_template_id + which are not assigned to configuration steps""" + + extra_attribute_line_ids = ( + product_template_id.attribute_line_ids + - product_template_id.config_step_line_ids.mapped("attribute_line_ids") + ) + return extra_attribute_line_ids + + def check_attributes_configuration( + self, attribute_line_ids, custom_vals, value_ids, final=True + ): + for line in attribute_line_ids: + # Validate custom values + attr = line.attribute_id + if attr.id in custom_vals: + attr.validate_custom_val(custom_vals[attr.id]) + if final: + common_vals = set(value_ids) & set(line.value_ids.ids) + custom_val = custom_vals.get(attr.id) + avail_val_ids = self.values_available( + line.value_ids.ids, + value_ids, + product_tmpl_id=self.product_tmpl_id, + ) + if ( + line.required + and avail_val_ids + and not common_vals + and not custom_val + ): + # TODO: Verify custom value type to be correct + raise ValidationError( + _("Required attribute '%s' is empty") % (attr.name) + ) + + @api.model + def validate_configuration( + self, + value_ids=None, + custom_vals=None, + product_tmpl_id=False, + final=True, + ): + """Verifies if the configuration values passed via value_ids and + custom_vals are valid + + :param value_ids: list of attribute value ids + :param custom_vals: custom values dict {attr_id: custom_val} + :param final: boolean marker to check required attributes. + pass false to check non-final configurations + + :returns: Error dict with reason of validation failure + or True + """ + # TODO: Raise ConfigurationError with reason + # Check if required values are missing for final configuration + if value_ids is None: + value_ids = self.value_ids.ids + + if product_tmpl_id: + product_tmpl = self.env["product.template"].browse(product_tmpl_id) + else: + product_tmpl = self.product_tmpl_id + + product_tmpl.ensure_one() + + if custom_vals is None: + custom_vals = self._get_custom_vals_dict() + open_step_lines = self.get_open_step_lines() + attribute_line_ids = open_step_lines.mapped("attribute_line_ids") + attribute_line_ids += self.get_extra_attribute_line_ids( + product_template_id=product_tmpl + ) + self.check_attributes_configuration( + attribute_line_ids, custom_vals, value_ids, final=final + ) + + # Check if all all the values passed are not restricted + avail_val_ids = self.values_available( + value_ids, value_ids, product_tmpl_id=product_tmpl_id + ) + if set(value_ids) - set(avail_val_ids): + restrict_val = list(set(value_ids) - set(avail_val_ids)) + product_att_values = self.env["product.attribute.value"].browse( + restrict_val + ) + group_by_attr = {} + for val in product_att_values: + if val.attribute_id in group_by_attr: + group_by_attr[val.attribute_id] += val + else: + group_by_attr[val.attribute_id] = val + + message = _("The following values are not available:") + for attr, val in group_by_attr.items(): + message += "\n {}: {}".format(attr.name, ", ".join(val.mapped("name"))) + raise ValidationError(message) + + # Check if custom values are allowed + custom_attr_ids = ( + product_tmpl.attribute_line_ids.filtered("custom") + .mapped("attribute_id") + .ids + ) + if not set(custom_vals.keys()) <= set(custom_attr_ids): + custom_attrs_with_error = list( + set(custom_vals.keys()) - set(custom_attr_ids) + ) + custom_attrs_with_error = self.env["product.attribute"].browse( + custom_attrs_with_error + ) + error_message = _( + "The following custom values are not permitted " + "according to the product template - %s.\n\nIt is possible " + "that a change has been made to allowed custom values " + "while your configuration was in process. Please reset your " + "current session and start over or contact your administrator" + " in order to proceed." + ) + message_vals = "" + for attr_id in custom_attrs_with_error: + message_vals += f"\n {attr_id.name}: {custom_vals.get(attr_id.id)}" + raise ValidationError(error_message % (message_vals)) + + # Check if there are multiple values passed for non-multi attributes + mono_attr_lines = product_tmpl.attribute_line_ids.filtered( + lambda line: not line.multi + ) + attrs_with_error = {} + for line in mono_attr_lines: + if len(set(line.value_ids.ids) & set(value_ids)) > 1: + wrong_vals = self.env["product.attribute.value"].browse( + set(line.value_ids.ids) & set(value_ids) + ) + attrs_with_error[line.attribute_id] = wrong_vals + if attrs_with_error: + error_message = _( + "The following multi values are not permitted " + "according to the product template - %s.\n\nIt is possible " + "that a change has been made to allowed multi values " + "while your configuration was in process. Please reset your " + "current session and start over or contact your administrator" + " in order to proceed." + ) + message_vals = "" + for attr_id, vals in attrs_with_error.items(): + message_vals += "\n {}: {}".format( + attr_id.name, ", ".join(vals.mapped("name")) + ) + raise ValidationError(error_message % (message_vals)) + return True + + @api.model + def search_variant(self, value_ids=None, product_tmpl_id=None): + """Searches product.variants with given value_ids and custom values + given in the custom_vals dict + + :param value_ids: list of product.attribute.values ids + :param custom_vals: dict {product.attribute.id: custom_value} + + :returns: product.product recordset of products matching domain + """ + if value_ids is None: + value_ids = self.value_ids.ids + + custom_value_id = self.get_custom_value_id() + value_ids = list(set(value_ids) - set(custom_value_id.ids)) + + if not product_tmpl_id: + product_tmpl_id = self.product_tmpl_id + if not product_tmpl_id: + raise ValidationError( + _( + "Cannot conduct search on an empty config session " + "without product_tmpl_id kwarg" + ) + ) + + domain = self.get_variant_search_domain( + product_tmpl_id=product_tmpl_id, value_ids=value_ids + ) + products = self.env["product.product"].search(domain) + + # At this point, we might have found products with all of the passed + # in values, but it might have more attributes! These are NOT + # matches + more_attrs = products.filtered( + lambda p: len(p.product_template_attribute_value_ids) != len(value_ids) + ) + products -= more_attrs + return products + + def search_session(self, product_tmpl_id, parent_id=None): + domain = self.get_session_search_domain( + product_tmpl_id=product_tmpl_id, parent_id=parent_id + ) + session = self.search(domain, order="create_date desc", limit=1) + return session + + @api.model + def create_get_session( + self, product_tmpl_id, parent_id=None, force_create=False, user_id=None + ): + if not force_create: + session = self.search_session( + product_tmpl_id=product_tmpl_id, parent_id=parent_id + ) + if session: + return session + vals = self.get_session_vals( + product_tmpl_id=product_tmpl_id, + parent_id=parent_id, + user_id=user_id, + ) + return self.create(vals) + + # TODO: Disallow duplicates + + def flatten_val_ids(self, value_ids): + """Return a list of value_ids from a list with a mix of ids + and list of ids (multiselection) + + :param value_ids: list of value ids or mix of ids and list of ids + (e.g: [1, 2, 3, [4, 5, 6]]) + :returns: flattened list of ids ([1, 2, 3, 4, 5, 6])""" + flatList = [] + for value in value_ids: + if isinstance(value, list): + for sub in value: + flatList.append(sub) + else: + flatList.append(value) + return flatList + + def formatPrices(self, prices=None, dp="Product Price"): + if prices is None: + prices = {} + dp = None + prices["taxes"] = formatLang(self.env, prices["taxes"], monetary=True, dp=dp) + prices["total"] = formatLang(self.env, prices["total"], monetary=True, dp=dp) + prices["vals"] = [ + (v[0], v[1], formatLang(self.env, v[2], monetary=True, dp=dp)) + for v in prices["vals"] + ] + return prices + + def encode_custom_values(self, custom_vals): + """Hook to alter the values of the custom values before creating + or writing + :param custom_vals: dict {product.attribute.id: custom_value} + :returns: list of custom values compatible with write and create + """ + attr_obj = self.env["product.attribute"] + binary_attribute_ids = attr_obj.search([("custom_type", "=", "binary")]).ids + custom_lines = [] + + for key, val in custom_vals.items(): + custom_vals = {"attribute_id": key} + # TODO: Is this extra check neccesairy as we already make + # the check in validate_configuration? + attr_obj.browse(key).validate_custom_val(val) + if key in binary_attribute_ids: + custom_vals.update({"attachment_ids": [(6, 0, val.ids)]}) + else: + custom_vals.update({"value": val}) + custom_lines.append((0, 0, custom_vals)) + return custom_lines + + @api.model + def get_child_specification(self, model, parent): + """return dictiory of onchange specification by + appending parent before each key""" + model_obj = self.env[model] + specs = model_obj._onchange_spec() + new_specs = {} + for key, val in specs.items(): + new_specs[f"{parent}.{key}"] = val + return new_specs + + @api.model + def get_onchange_specifications(self, model): + """return onchange specification + - same functionality by _onchange_spec + - needed this method because odoo don't add specification for fields + one2many or many2many there is view-reference(using : tree_view_ref) + intead of view in that field""" + model_obj = self.env[model] + specs = model_obj._onchange_spec() + + # TODO :- Commented a code and ths code already base in a odoo base modules. + # for name, field in model_obj._fields.items(): + # if field.type not in ["one2many", "many2many"]: + # continue + # ch_specs = self.get_child_specification( + # model=field.comodel_name, parent=name + # ) + # specs.update(ch_specs) + + return specs + + @api.model + def get_vals_to_write(self, values, model): + """Return values in formate excepted by write/create methods + - same functionality by _convert_to_write + - needed this method because odoo don't call convert to write + for the many2many/one2many fields""" + model_obj = self.env[model] + values = model_obj._convert_to_write(values) + fields = model_obj._fields + for key, vals in values.items(): + if not isinstance(vals, list): + continue + new_lst = [] + for line in vals: + new_line = line + if line and isinstance(line[-1], dict): + new_line = line[:-1] + ( + self.get_vals_to_write( + values=line[-1], model=fields[key].comodel_name + ), + ) + new_lst.append(new_line) + values[key] = new_lst + return values + + +class ProductConfigSessionCustomValue(models.Model): + _name = "product.config.session.custom.value" + _rec_name = "attribute_id" + _description = "Product Config Session Custom Value" + + @api.depends("attribute_id", "attribute_id.uom_id") + def _compute_val_name(self): + for attr_val_custom in self: + uom = attr_val_custom.attribute_id.uom_id.name + attr_val_custom.name = "{}{}".format( + attr_val_custom.value, + (" %s" % uom) or "", + ) + + name = fields.Char(readonly=True, compute="_compute_val_name", store=True) + attribute_id = fields.Many2one( + comodel_name="product.attribute", string="Attribute", required=True + ) + cfg_session_id = fields.Many2one( + comodel_name="product.config.session", + required=True, + ondelete="cascade", + string="Session", + ) + value = fields.Char(help="Custom value held as string") + attachment_ids = fields.Many2many( + comodel_name="ir.attachment", + relation="product_config_session_custom_value_attachment_rel", + column1="cfg_sesion_custom_val_id", + column2="attachment_id", + string="Attachments", + ) + + def eval(self): + """Return custom value evaluated using the related custom field type""" + field_type = self.attribute_id.custom_type + if field_type == "binary": + vals = self.attachment_ids.mapped("datas") + if len(vals) == 1: + return vals[0] + return vals + elif field_type == "integer": + return int(self.value) + elif field_type == "float": + return float(self.value) + return self.value + + @api.constrains("cfg_session_id", "attribute_id") + def unique_attribute(self): + for custom_val in self: + if ( + len( + custom_val.cfg_session_id.custom_value_ids.filtered( + lambda x: x.attribute_id == custom_val.attribute_id + ) + ) + > 1 + ): + raise ValidationError( + _("Configuration cannot have the " "same value inserted twice") + ) + + # @api.constrains('cfg_session_id.value_ids') + # def custom_only(self): + # """Verify that the attribute_id is not present in vals as well""" + # import ipdb;ipdb.set_trace() + # if self.cfg_session_id.value_ids.filtered( + # lambda x: x.attribute_id == self.attribute_id): + # raise ValidationError( + # _("Configuration cannot have a selected option and a custom " + # "value with the same attribute") + # ) + + @api.constrains("attachment_ids", "value") + def check_custom_type(self): + for custom_val in self: + custom_type = custom_val.attribute_id.custom_type + if custom_val.value and custom_type == "binary": + raise ValidationError( + _( + "Attribute custom type is binary, attachments are the " + "only accepted values with this custom field type" + ) + ) + if custom_val.attachment_ids and custom_type != "binary": + raise ValidationError( + _( + "Attribute custom type must be 'binary' for saving " + "attachments to custom value" + ) + ) diff --git a/product_configurator/pyproject.toml b/product_configurator/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/product_configurator/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/product_configurator/readme/CONTRIBUTORS.md b/product_configurator/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..f01737c2d2 --- /dev/null +++ b/product_configurator/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Nikul Chaudhary \<\> +- Murtaza Mithaiwala \<\> diff --git a/product_configurator/readme/DESCRIPTION.md b/product_configurator/readme/DESCRIPTION.md new file mode 100644 index 0000000000..d3dc3ea6eb --- /dev/null +++ b/product_configurator/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +This module has all the mechanics to support product configuration. It +serves as a base dependency for configuration interfaces. diff --git a/product_configurator/security/configurator_security.xml b/product_configurator/security/configurator_security.xml new file mode 100644 index 0000000000..8d7f835f04 --- /dev/null +++ b/product_configurator/security/configurator_security.xml @@ -0,0 +1,38 @@ + + + + + Product Configurator + + + + User + + + + + + Manager + + + + + + + + + + + + + + diff --git a/product_configurator/security/ir.model.access.csv b/product_configurator/security/ir.model.access.csv new file mode 100644 index 0000000000..d9bfcf0869 --- /dev/null +++ b/product_configurator/security/ir.model.access.csv @@ -0,0 +1,40 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +product_configurator_config_line,Config Line,model_product_config_line,group_product_configurator,1,0,0,0 +product_configurator_config_image,Config Image,model_product_config_image,group_product_configurator,1,0,0,0 +product_configurator_config_step,Config Step,model_product_config_step,group_product_configurator,1,0,0,0 +product_configurator_config_step_line,Config Step Line,model_product_config_step_line,group_product_configurator,1,0,0,0 +product_configurator_config_domain,Config Domain,model_product_config_domain,group_product_configurator,1,0,0,0 +product_configurator_config_domain_line,Config Domain Line,model_product_config_domain_line,group_product_configurator,1,0,0,0 +product_configurator_custom_attribute_value,Attribute Value Line,model_product_attribute_value_line,group_product_configurator,1,0,0,0 +product_configurator_config_session,Config Session,model_product_config_session,group_product_configurator,1,1,1,1 +product_configurator_config_session_custom_value,Config Session Custom Value,model_product_config_session_custom_value,group_product_configurator,1,1,1,1 +user_config_line,User Config Line,model_product_config_line,base.group_user,1,0,0,0 +user_config_image,User Config Image,model_product_config_image,base.group_user,1,0,0,0 +user_config_step,User Config Step,model_product_config_step,base.group_user,1,0,0,0 +user_config_step_line,User Config Step Line,model_product_config_step_line,base.group_user,1,0,0,0 +user_config_domain_line,User Config Domain Line,model_product_config_domain_line,base.group_user,1,0,0,0 +user_config_domain,User Config Domain,model_product_config_domain,base.group_user,1,0,0,0 +user_custom_attribute_value,User Attribute Value Line,model_product_attribute_value_line,base.group_user,1,0,0,0 +user_config_session,User Config Session,model_product_config_session,base.group_user,1,0,0,0 +user_config_session_custom_value,User Config Session Custom Value,model_product_config_session_custom_value,base.group_user,1,0,0,0 +portal_config_image,Portal Config Image,model_product_config_image,base.group_portal,1,0,0,0 +portal_config_step,Portal Config Step,model_product_config_step,base.group_portal,1,0,0,0 +portal_config_session,Portal Config Session,model_product_config_session,base.group_portal,1,0,0,0 +portal_config_session_custom_value,Portal Config Session Custom Value,model_product_config_session_custom_value,base.group_portal,1,0,0,0 +portal_configurator_config_line,Portal Config Line,model_product_config_line,base.group_portal,1,0,0,0 +portal_configurator_config_step_line,Portal Config Step Line,model_product_config_step_line,base.group_portal,1,0,0,0 +portal_configurator_config_domain,Portal Config Domain,model_product_config_domain,base.group_portal,1,0,0,0 +portal_configurator_config_domain_line,Portal Config Domain Line,model_product_config_domain_line,base.group_portal,1,0,0,0 +product_configurator_config_line_manager,Config Line Manager,product_configurator.model_product_config_line,product_configurator.group_product_configurator_manager,1,1,1,1 +product_configurator_config_image_manager,Config Image Manager,product_configurator.model_product_config_image,product_configurator.group_product_configurator_manager,1,1,1,1 +product_configurator_config_step_manager,Config Step Manager,product_configurator.model_product_config_step,product_configurator.group_product_configurator_manager,1,1,1,1 +product_configurator_config_step_line_manager,Config Step Line Manager,product_configurator.model_product_config_step_line,product_configurator.group_product_configurator_manager,1,1,1,1 +product_configurator_config_domain_manager,Config Domain Manager,product_configurator.model_product_config_domain,product_configurator.group_product_configurator_manager,1,1,1,1 +product_configurator_config_domain_line_manager,Config Domain Line Manager,product_configurator.model_product_config_domain_line,product_configurator.group_product_configurator_manager,1,1,1,1 +product_configurator_custom_attribute_value_manager,Attribute Value Line Manager,product_configurator.model_product_attribute_value_line,product_configurator.group_product_configurator_manager,1,1,1,1 +access_product_template_product_config_user,product.template Product Config user,product.model_product_template,product_configurator.group_product_configurator,1,0,0,0 +access_product_template_product_config_manager,product.template Product Config Manager,product.model_product_template,product_configurator.group_product_configurator_manager,1,1,1,1 +access_product_product_product_config_user,product.product Product Config user,product.model_product_product,product_configurator.group_product_configurator,1,0,0,0 +access_product_product_product_config_manager,product.product Product Config Manager,product.model_product_product,product_configurator.group_product_configurator_manager,1,1,1,1 +access_product_attribute_line_product_config_manager,product.attribute line Product Config Manager,product.model_product_template_attribute_line,product_configurator.group_product_configurator_manager,1,1,1,1 +access_product_configurator_group,product_configurator,model_product_configurator,product_configurator.group_product_configurator,1,1,1,1 diff --git a/product_configurator/static/description/configurable-template.png b/product_configurator/static/description/configurable-template.png new file mode 100644 index 0000000000..860c818ea0 Binary files /dev/null and b/product_configurator/static/description/configurable-template.png differ diff --git a/product_configurator/static/description/cover.png b/product_configurator/static/description/cover.png new file mode 100644 index 0000000000..be5e2578ce Binary files /dev/null and b/product_configurator/static/description/cover.png differ diff --git a/product_configurator/static/description/icon.png b/product_configurator/static/description/icon.png new file mode 100644 index 0000000000..d5d307c648 Binary files /dev/null and b/product_configurator/static/description/icon.png differ diff --git a/product_configurator/static/description/index.html b/product_configurator/static/description/index.html new file mode 100644 index 0000000000..b30bc62175 --- /dev/null +++ b/product_configurator/static/description/index.html @@ -0,0 +1,75 @@ +
+
+
+

Odoo Product Configurator

+

Generate products on-demand, easy and error-free

+
+
+
+ Pledra +
+
+
+
+ +
+
+

Select your template

+
+
+ +
+
+
+
+ +
+
+

Choose your options and get live image updates

+
+
+ +
+
+
+
+ +
+
+

Custom values and multiple selections supported

+
+
+ +
+
+
+
+ +
+
+

The wizard is generated automatically for you!

+

Just define the attributes and rules on the template and you are done

+
+
+ +
+
+
+
+ +
+
+

Compatible with Odoo Enterprise and Community

+

Odoo versions supported: 8 / 9 / 10

+
+
+ +
+
+
+
+ +
+
+
+
diff --git a/product_configurator/static/description/odoo-community-interface.png b/product_configurator/static/description/odoo-community-interface.png new file mode 100644 index 0000000000..e741d61531 Binary files /dev/null and b/product_configurator/static/description/odoo-community-interface.png differ diff --git a/product_configurator/static/description/odoo-enterprise-interface.png b/product_configurator/static/description/odoo-enterprise-interface.png new file mode 100644 index 0000000000..5b13b3dd19 Binary files /dev/null and b/product_configurator/static/description/odoo-enterprise-interface.png differ diff --git a/product_configurator/static/description/pledra-logo.png b/product_configurator/static/description/pledra-logo.png new file mode 100644 index 0000000000..b82d4ab00d Binary files /dev/null and b/product_configurator/static/description/pledra-logo.png differ diff --git a/product_configurator/static/description/quotation-updated.png b/product_configurator/static/description/quotation-updated.png new file mode 100644 index 0000000000..8b0f5aafb3 Binary files /dev/null and b/product_configurator/static/description/quotation-updated.png differ diff --git a/product_configurator/static/description/quotation.png b/product_configurator/static/description/quotation.png new file mode 100644 index 0000000000..8ed8013836 Binary files /dev/null and b/product_configurator/static/description/quotation.png differ diff --git a/product_configurator/static/description/wizard-color.png b/product_configurator/static/description/wizard-color.png new file mode 100644 index 0000000000..396e046b74 Binary files /dev/null and b/product_configurator/static/description/wizard-color.png differ diff --git a/product_configurator/static/description/wizard-last-step.png b/product_configurator/static/description/wizard-last-step.png new file mode 100644 index 0000000000..3d89020dc4 Binary files /dev/null and b/product_configurator/static/description/wizard-last-step.png differ diff --git a/product_configurator/static/description/wizard-template.png b/product_configurator/static/description/wizard-template.png new file mode 100644 index 0000000000..948ce549be Binary files /dev/null and b/product_configurator/static/description/wizard-template.png differ diff --git a/product_configurator/static/img/2-series-coupe-black-star-spoke-384.jpg b/product_configurator/static/img/2-series-coupe-black-star-spoke-384.jpg new file mode 100644 index 0000000000..a20f555251 Binary files /dev/null and b/product_configurator/static/img/2-series-coupe-black-star-spoke-384.jpg differ diff --git a/product_configurator/static/img/2-series-coupe-black-star-spoke-387.jpg b/product_configurator/static/img/2-series-coupe-black-star-spoke-387.jpg new file mode 100644 index 0000000000..f7fa6043e8 Binary files /dev/null and b/product_configurator/static/img/2-series-coupe-black-star-spoke-387.jpg differ diff --git a/product_configurator/static/img/2-series-coupe-black.jpg b/product_configurator/static/img/2-series-coupe-black.jpg new file mode 100644 index 0000000000..77584cb03e Binary files /dev/null and b/product_configurator/static/img/2-series-coupe-black.jpg differ diff --git a/product_configurator/static/img/2-series-coupe-red-star-spoke-384.jpg b/product_configurator/static/img/2-series-coupe-red-star-spoke-384.jpg new file mode 100644 index 0000000000..47b2937e30 Binary files /dev/null and b/product_configurator/static/img/2-series-coupe-red-star-spoke-384.jpg differ diff --git a/product_configurator/static/img/2-series-coupe-red-star-spoke-387.jpg b/product_configurator/static/img/2-series-coupe-red-star-spoke-387.jpg new file mode 100644 index 0000000000..92d4f1ab75 Binary files /dev/null and b/product_configurator/static/img/2-series-coupe-red-star-spoke-387.jpg differ diff --git a/product_configurator/static/img/2-series-coupe-silver-star-spoke-384.jpg b/product_configurator/static/img/2-series-coupe-silver-star-spoke-384.jpg new file mode 100644 index 0000000000..5e747277eb Binary files /dev/null and b/product_configurator/static/img/2-series-coupe-silver-star-spoke-384.jpg differ diff --git a/product_configurator/static/img/2-series-coupe-silver-star-spoke-387.jpg b/product_configurator/static/img/2-series-coupe-silver-star-spoke-387.jpg new file mode 100644 index 0000000000..b62f75fce7 Binary files /dev/null and b/product_configurator/static/img/2-series-coupe-silver-star-spoke-387.jpg differ diff --git a/product_configurator/static/img/2-series-coupe-silver.jpg b/product_configurator/static/img/2-series-coupe-silver.jpg new file mode 100644 index 0000000000..2c5e003a50 Binary files /dev/null and b/product_configurator/static/img/2-series-coupe-silver.jpg differ diff --git a/product_configurator/static/img/2-series-coupe.jpg b/product_configurator/static/img/2-series-coupe.jpg new file mode 100644 index 0000000000..0e4b3df727 Binary files /dev/null and b/product_configurator/static/img/2-series-coupe.jpg differ diff --git a/product_configurator/static/img/product-advantage.jpg b/product_configurator/static/img/product-advantage.jpg new file mode 100644 index 0000000000..60a6e1c1cc Binary files /dev/null and b/product_configurator/static/img/product-advantage.jpg differ diff --git a/product_configurator/static/img/product-armrest.jpg b/product_configurator/static/img/product-armrest.jpg new file mode 100644 index 0000000000..97ab6c9551 Binary files /dev/null and b/product_configurator/static/img/product-armrest.jpg differ diff --git a/product_configurator/static/img/product-engine.jpg b/product_configurator/static/img/product-engine.jpg new file mode 100644 index 0000000000..2f1b6f6be5 Binary files /dev/null and b/product_configurator/static/img/product-engine.jpg differ diff --git a/product_configurator/static/img/product-luxury-line.jpg b/product_configurator/static/img/product-luxury-line.jpg new file mode 100644 index 0000000000..0fd9248e59 Binary files /dev/null and b/product_configurator/static/img/product-luxury-line.jpg differ diff --git a/product_configurator/static/img/product-m-sport.jpg b/product_configurator/static/img/product-m-sport.jpg new file mode 100644 index 0000000000..0cfc029d9e Binary files /dev/null and b/product_configurator/static/img/product-m-sport.jpg differ diff --git a/product_configurator/static/img/product-paint-silver.jpg b/product_configurator/static/img/product-paint-silver.jpg new file mode 100644 index 0000000000..b49c8619d4 Binary files /dev/null and b/product_configurator/static/img/product-paint-silver.jpg differ diff --git a/product_configurator/static/img/product-smoker-package.jpg b/product_configurator/static/img/product-smoker-package.jpg new file mode 100644 index 0000000000..8cdaf37412 Binary files /dev/null and b/product_configurator/static/img/product-smoker-package.jpg differ diff --git a/product_configurator/static/img/product-sport-line.jpg b/product_configurator/static/img/product-sport-line.jpg new file mode 100644 index 0000000000..7f18487aa5 Binary files /dev/null and b/product_configurator/static/img/product-sport-line.jpg differ diff --git a/product_configurator/static/img/product-sunroof.jpg b/product_configurator/static/img/product-sunroof.jpg new file mode 100644 index 0000000000..11c5c87587 Binary files /dev/null and b/product_configurator/static/img/product-sunroof.jpg differ diff --git a/product_configurator/static/img/product-towhook.jpg b/product_configurator/static/img/product-towhook.jpg new file mode 100644 index 0000000000..2f4d10f8b7 Binary files /dev/null and b/product_configurator/static/img/product-towhook.jpg differ diff --git a/product_configurator/static/img/product-transmission-steptronic-sport.jpg b/product_configurator/static/img/product-transmission-steptronic-sport.jpg new file mode 100644 index 0000000000..84e4956301 Binary files /dev/null and b/product_configurator/static/img/product-transmission-steptronic-sport.jpg differ diff --git a/product_configurator/static/img/product-transmission-steptronic.jpg b/product_configurator/static/img/product-transmission-steptronic.jpg new file mode 100644 index 0000000000..a46a0f7ff4 Binary files /dev/null and b/product_configurator/static/img/product-transmission-steptronic.jpg differ diff --git a/product_configurator/static/src/js/boolean_button_widget.esm.js b/product_configurator/static/src/js/boolean_button_widget.esm.js new file mode 100644 index 0000000000..ef78926bc7 --- /dev/null +++ b/product_configurator/static/src/js/boolean_button_widget.esm.js @@ -0,0 +1,60 @@ +/** @odoo-module **/ +/*eslint-disable*/ +import {registry} from "@web/core/registry"; +import {onMounted, onRendered, useRef, useState} from "@odoo/owl"; +import {BooleanField, booleanField} from "@web/views/fields/boolean/boolean_field"; +import {standardFieldProps} from "@web/views/fields/standard_field_props"; + +export class BooleanButton extends BooleanField { + // Static template = "product_configurator.BooleanButtonField"; + + setup() { + super.setup(); + this.state1 = useState({value: 0}); + this.root = useRef("root"); + onMounted(() => { + this.updateConfigurableButton(); + }); + onRendered(() => { + this.updateConfigurableButton(); + }); + } + + updateConfigurableButton() { + this.text = this.props.value + ? this.props.activeString + : this.props.inactiveString; + this.hover = this.props.value + ? this.props.inactiveString + : this.props.activeString; + var val_color = this.props.value ? "text-success" : "text-danger"; + var hover_color = this.props.value ? "text-danger" : "text-success"; + var $val = $("") + .addClass("o_stat_text o_boolean_button o_not_hover " + val_color) + .text(this.text); + var $hover = $("") + .addClass("o_stat_text o_boolean_button o_hover d-none " + hover_color) + .text(this.hover); + $(this.root.el).empty(); + $(this.root.el).append($val).append($hover); + } +} + +export const BooleanButtonField = { + ...booleanField, + component: BooleanButton, + extractProps: ({options}) => { + return { + activeString: options.active, + inactiveString: options.inactive, + }; + }, +}; + +BooleanButton.props = { + ...standardFieldProps, + activeString: {type: String}, + inactiveString: {type: String, optional: true}, +}; + +registry.category("fields").add("boolean_button", BooleanButtonField); diff --git a/product_configurator/static/src/js/boolean_button_widget.xml b/product_configurator/static/src/js/boolean_button_widget.xml new file mode 100644 index 0000000000..b1d6447d50 --- /dev/null +++ b/product_configurator/static/src/js/boolean_button_widget.xml @@ -0,0 +1,16 @@ + + + + +
+ +
+
+ +
diff --git a/product_configurator/static/src/js/form_widgets.esm.js b/product_configurator/static/src/js/form_widgets.esm.js new file mode 100644 index 0000000000..81efb2449e --- /dev/null +++ b/product_configurator/static/src/js/form_widgets.esm.js @@ -0,0 +1,80 @@ +/* @odoo-module */ +/*eslint-disable*/ +import {patch} from "@web/core/utils/patch"; +import {FormController} from "@web/views/form/form_controller"; +import {ListController} from "@web/views/list/list_controller"; +import {KanbanController} from "@web/views/kanban/kanban_controller"; +import {onMounted} from "@odoo/owl"; + +patch(FormController.prototype, { + setup() { + super.setup(...arguments); + onMounted(() => { + var form_element = this.rootRef.el; + var self = this; + if ( + self.model.config.resModel === "product.product" && + self.model.config.context.custom_create_variant + ) { + var buttons = form_element.querySelector( + ".o_control_panel_main_buttons" + ); + var createButtons = buttons.querySelectorAll(".o_form_button_create"); + createButtons.forEach((button) => { + button.style.display = "none"; + }); + } + }); + }, + async beforeExecuteActionButton(clickParams) { + if (clickParams.special === "no_save") { + delete clickParams.special; + return true; + } + return super.beforeExecuteActionButton(...arguments); + }, +}); + +patch(ListController.prototype, { + setup() { + super.setup(...arguments); + onMounted(() => { + var form_element = this.rootRef.el; + var self = this; + if ( + self.model.config.resModel === "product.product" && + self.model.config.context.custom_create_variant + ) { + var buttons = form_element.querySelector( + ".o_control_panel_main_buttons" + ); + var createButtons = buttons.querySelectorAll(".o_list_button_add"); + createButtons.forEach((button) => { + button.style.display = "none"; + }); + } + }); + }, +}); + +patch(KanbanController.prototype, { + setup() { + super.setup(...arguments); + onMounted(() => { + var form_element = this.rootRef.el; + var self = this; + if ( + self.model.config.resModel === "product.product" && + self.model.config.context.custom_create_variant + ) { + var buttons = form_element.querySelector( + ".o_control_panel_main_buttons" + ); + var createButtons = buttons.querySelectorAll(".o-kanban-button-new"); + createButtons.forEach((button) => { + button.style.display = "none"; + }); + } + }); + }, +}); diff --git a/product_configurator/static/src/js/relational_fields.esm.js b/product_configurator/static/src/js/relational_fields.esm.js new file mode 100644 index 0000000000..93777106a7 --- /dev/null +++ b/product_configurator/static/src/js/relational_fields.esm.js @@ -0,0 +1,14 @@ +/** @odoo-module **/ +/*eslint-disable*/ +import {patch} from "@web/core/utils/patch"; +import {Many2OneField} from "@web/views/fields/many2one/many2one_field"; + +patch(Many2OneField.prototype, { + computeActiveActions(props) { + var element = super.computeActiveActions(...arguments); + if (element === undefined) { + return $(); + } + return element; + }, +}); diff --git a/product_configurator/static/src/js/view.js b/product_configurator/static/src/js/view.js new file mode 100644 index 0000000000..fe5c68edee --- /dev/null +++ b/product_configurator/static/src/js/view.js @@ -0,0 +1,14 @@ +/** @odoo-module **/ + +import {patch} from "@web/core/utils/patch"; +import { View } from "@web/views/view"; + +patch(View.prototype, { + async loadView(props){ + var element = super.loadView(...arguments); + if (this?.props?.context?.is_product_configurator){ + this.env.bus.trigger("CLEAR-CACHES"); + } + return element + }, +}); \ No newline at end of file diff --git a/product_configurator/static/src/scss/form_widget.scss b/product_configurator/static/src/scss/form_widget.scss new file mode 100644 index 0000000000..7a9323bc16 --- /dev/null +++ b/product_configurator/static/src/scss/form_widget.scss @@ -0,0 +1,20 @@ +.oe_stat_button { + &:hover { + .o_boolean_button.o_not_hover { + display: none; + } + .o_boolean_button.o_hover { + display: inline-block !important; + } + } +} + +.oe_prod_config_image { + img { + min-height: 30px; + } +} + +.pull-right { + float: right; +} diff --git a/product_configurator/tests/__init__.py b/product_configurator/tests/__init__.py new file mode 100644 index 0000000000..435b31b0a0 --- /dev/null +++ b/product_configurator/tests/__init__.py @@ -0,0 +1,9 @@ +from . import test_product_configurator_test_cases + +# from . import test_create +# from . import test_configuration_rules +from . import test_product + +# from . import test_product_attribute +# from . import test_product_config +# from . import test_wizard diff --git a/product_configurator/tests/test_configuration_rules.py b/product_configurator/tests/test_configuration_rules.py new file mode 100644 index 0000000000..50e197a33d --- /dev/null +++ b/product_configurator/tests/test_configuration_rules.py @@ -0,0 +1,128 @@ +from odoo import SUPERUSER_ID +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + + +class ConfigurationRules(TransactionCase): + def setUp(self): + super().setUp() + + self.cfg_tmpl = self.env.ref("product_configurator.bmw_2_series") + self.cfg_session = self.env["product.config.session"].create( + {"product_tmpl_id": self.cfg_tmpl.id, "user_id": SUPERUSER_ID} + ) + + attribute_vals = self.cfg_tmpl.attribute_line_ids.mapped("value_ids") + self.attr_vals = self.cfg_tmpl.attribute_line_ids.mapped("value_ids") + + self.attr_val_ext_ids = { + v: k for k, v in attribute_vals.get_external_id().items() + } + + def get_attr_val_ids(self, ext_ids): + """Return a list of database ids using the external_ids + passed via ext_ids argument""" + + value_ids = [] + + attr_val_prefix = "product_configurator.product_attribute_value_%s" + + for ext_id in ext_ids: + if ext_id in self.attr_val_ext_ids: + value_ids.append(self.attr_val_ext_ids[ext_id]) + elif attr_val_prefix % ext_id in self.attr_val_ext_ids: + value_ids.append(self.attr_val_ext_ids[attr_val_prefix % ext_id]) + + return value_ids + + def test_valid_configuration(self): + """Test validation of a valid configuration""" + + conf = [ + "gasoline", + "228i", + "model_luxury_line", + "silver", + "rims_384", + "tapistry_black", + "steptronic", + "smoker_package", + "tow_hook", + ] + + attr_val_ids = self.get_attr_val_ids(conf) + validation = self.cfg_session.validate_configuration(attr_val_ids) + self.assertTrue(validation, "Valid configuration failed validation") + + def test_invalid_configuration(self): + conf = [ + "diesel", + "228i", + "model_luxury_line", + "silver", + "rims_384", + "tapistry_black", + "steptronic", + "smoker_package", + "tow_hook", + ] + + attr_val_ids = self.get_attr_val_ids(conf) + with self.assertRaises(ValidationError): + self.cfg_session.validate_configuration(attr_val_ids) + + def test_missing_val_configuration(self): + conf = [ + "diesel", + "228i", + "model_luxury_line", + "rims_384", + "tapistry_black", + "steptronic", + "smoker_package", + "tow_hook", + ] + + attr_val_ids = self.get_attr_val_ids(conf) + with self.assertRaises(ValidationError): + self.cfg_session.validate_configuration(attr_val_ids) + + def test_invalid_multi_configuration(self): + conf = [ + "gasoline", + "228i", + "model_luxury_line", + "silver", + "red", + "rims_384", + "tapistry_black", + "steptronic", + "smoker_package", + "tow_hook", + ] + + attr_val_ids = self.get_attr_val_ids(conf) + with self.assertRaises(ValidationError): + self.cfg_session.validate_configuration(attr_val_ids) + + def test_invalid_custom_value_configuration(self): + conf = [ + "gasoline", + "228i", + "model_luxury_line", + "rims_384", + "tapistry_black", + "steptronic", + "smoker_package", + "tow_hook", + ] + + attr_color_id = self.env.ref("product_configurator.product_attribute_color") + + custom_vals = {attr_color_id: {"value": "#fefefe"}} + + attr_val_ids = self.get_attr_val_ids(conf) + with self.assertRaises(ValidationError): + self.cfg_session.validate_configuration(attr_val_ids, custom_vals) + + # TODO: Test configuration with disallowed custom type value diff --git a/product_configurator/tests/test_create.py b/product_configurator/tests/test_create.py new file mode 100644 index 0000000000..47c5fab260 --- /dev/null +++ b/product_configurator/tests/test_create.py @@ -0,0 +1,183 @@ +from odoo.tests.common import TransactionCase + + +class ConfigurationCreate(TransactionCase): + def setUp(self): + super().setUp() + + self.ProductConfWizard = self.env["product.configurator"] + self.config_product = self.env.ref("product_configurator.bmw_2_series") + self.product_category = self.env.ref("product.product_category_5") + + # attributes + self.attr_fuel = self.env.ref("product_configurator.product_attribute_fuel") + self.attr_engine = self.env.ref("product_configurator.product_attribute_engine") + self.attr_color = self.env.ref("product_configurator.product_attribute_color") + self.attr_rims = self.env.ref("product_configurator.product_attribute_rims") + self.attr_model_line = self.env.ref( + "product_configurator.product_attribute_model_line" + ) + self.attr_tapistry = self.env.ref( + "product_configurator.product_attribute_tapistry" + ) + self.attr_transmission = self.env.ref( + "product_configurator.product_attribute_transmission" + ) + self.attr_options = self.env.ref( + "product_configurator.product_attribute_options" + ) + + # values + self.value_gasoline = self.env.ref( + "product_configurator.product_attribute_value_gasoline" + ) + self.value_218i = self.env.ref( + "product_configurator.product_attribute_value_218i" + ) + self.value_220i = self.env.ref( + "product_configurator.product_attribute_value_220i" + ) + self.value_red = self.env.ref( + "product_configurator.product_attribute_value_red" + ) + self.value_rims_378 = self.env.ref( + "product_configurator.product_attribute_value_rims_378" + ) + self.value_sport_line = self.env.ref( + "product_configurator.product_attribute_value_sport_line" + ) + self.value_model_sport_line = self.env.ref( + "product_configurator.product_attribute_value_model_sport_line" + ) + self.value_tapistry = self.env.ref( + "product_configurator.product_attribute_value_tapistry" + "_oyster_black" + ) + self.value_transmission = self.env.ref( + "product_configurator.product_attribute_value_steptronic" + ) + self.value_options_1 = self.env.ref( + "product_configurator.product_attribute_value_smoker_package" + ) + self.value_options_2 = self.env.ref( + "product_configurator.product_attribute_value_sunroof" + ) + + def test_01_create(self): + """Test configuration item does not make variations""" + + attr_test = self.env["product.attribute"].create( + { + "name": "Test", + "value_ids": [ + (0, 0, {"name": "1"}), + (0, 0, {"name": "2"}), + ], + } + ) + + test_template = self.env["product.template"].create( + { + "name": "Test Configuration", + "config_ok": True, + "type": "consu", + "categ_id": self.product_category.id, + "attribute_line_ids": [ + ( + 0, + 0, + { + "attribute_id": attr_test.id, + "value_ids": [ + (6, 0, attr_test.value_ids.ids), + ], + "required": True, + }, + ), + ], + } + ) + + self.assertEqual( + len(test_template.product_variant_ids), + 0, + "Create should not have any variants", + ) + + def test_02_previous_step_incompatible_changes(self): + """Test changes in previous steps which would makes + values in next configuration steps invalid""" + + product_config_wizard = self.ProductConfWizard.create( + { + "product_tmpl_id": self.config_product.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_fuel.id}": self.value_gasoline.id, + f"__attribute_{self.attr_engine.id}": self.value_218i.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_color.id}": self.value_red.id, + f"__attribute_{self.attr_rims.id}": self.value_rims_378.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_model_line.id}": self.value_sport_line.id, + } + ) + product_config_wizard.action_previous_step() + product_config_wizard.action_previous_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_engine.id}": self.value_220i.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_model_line.id}": self.value_model_sport_line.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_tapistry.id}": self.value_tapistry.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_transmission.id}": self.value_transmission.id, + f"__attribute_{self.attr_options.id}": [ + [6, 0, [self.value_options_1.id, self.value_options_2.id]] + ], + } + ) + product_config_wizard.action_next_step() + value_ids = ( + self.value_gasoline + + self.value_220i + + self.value_red + + self.value_rims_378 + + self.value_model_sport_line + + self.value_tapistry + + self.value_transmission + + self.value_options_1 + + self.value_options_2 + ) + new_variant = self.config_product.product_variant_ids.filtered( + lambda variant: variant.attribute_value_ids == value_ids + ) + self.assertNotEqual( + new_variant.id, + False, + "Variant not generated at the end of the configuration process", + ) diff --git a/product_configurator/tests/test_product.py b/product_configurator/tests/test_product.py new file mode 100644 index 0000000000..5d8f8cec2c --- /dev/null +++ b/product_configurator/tests/test_product.py @@ -0,0 +1,710 @@ +from odoo.exceptions import ValidationError + +from ..tests.test_product_configurator_test_cases import ProductConfiguratorTestCases + + +class TestProduct(ProductConfiguratorTestCases): + def setUp(self): + super().setUp() + self.productTemplate = self.env["product.template"] + self.productAttributeLine = self.env["product.template.attribute.line"] + self.productConfigStepLine = self.env["product.config.step.line"] + self.product_category = self.env.ref("product.product_category_5") + self.attributelinefuel = self.env.ref( + "product_configurator.product_attribute_line_2_series_fuel" + ) + self.attributelineengine = self.env.ref( + "product_configurator.product_attribute_line_2_series_engine" + ) + self.value_diesel = self.env.ref( + "product_configurator.product_attribute_value_diesel" + ) + self.value_218d = self.env.ref( + "product_configurator.product_attribute_value_218d" + ) + self.value_220d = self.env.ref( + "product_configurator.product_attribute_value_220d" + ) + self.value_silver = self.env.ref( + "product_configurator.product_attribute_value_silver" + ) + self.config_step_engine = self.env.ref( + "product_configurator.config_step_engine" + ) + self.config_step_body = self.env.ref("product_configurator.config_step_body") + self.product_tmpl_id = self.env["product.template"].create( + { + "name": "Test Configuration", + "config_ok": True, + "type": "consu", + "categ_id": self.product_category.id, + } + ) + # create attribute line 1 + self.attributeLine1 = self.productAttributeLine.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + "attribute_id": self.attr_fuel.id, + "value_ids": [(6, 0, [self.value_gasoline.id, self.value_diesel.id])], + "required": True, + } + ) + # create attribute line 2 + self.attributeLine2 = self.productAttributeLine.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + "attribute_id": self.attr_engine.id, + "value_ids": [ + ( + 6, + 0, + [ + self.value_218i.id, + self.value_220i.id, + self.value_218d.id, + self.value_220d.id, + ], + ) + ], + "required": True, + } + ) + # create attribute line 3 + self.attributeLine3 = self.productAttributeLine.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + "attribute_id": self.attr_color.id, + "value_ids": [(6, 0, [self.value_red.id, self.value_silver.id])], + "required": True, + } + ) + + def _get_product_id(self): + self._configure_product_nxt_step() + return self.config_product.product_variant_ids + + def test_00__compute_template_attr_vals(self): + value_ids = self.product_tmpl_id.attribute_line_ids.mapped("value_ids") + self.product_tmpl_id._compute_template_attr_vals() + self.assertEqual( + value_ids, + self.product_tmpl_id.attribute_line_val_ids, + "Error: if value are different\ + Method: _compute_template_attr_vals() ", + ) + + def test_01_set_weight(self): + self.product_tmpl_id.weight = 120 + self.product_tmpl_id._set_weight() + self.assertEqual( + self.product_tmpl_id.weight, + self.product_tmpl_id.weight_dummy, + "Error: If set diffrent value for dummy_weight\ + Method: _set_weight()", + ) + self.product_tmpl_id.config_ok = False + set_weight = self.product_tmpl_id._set_weight() + self.assertIsNone( + set_weight, + "Error: If Value none\ + Method: _set_weight()", + ) + + def test_02_compute_weight(self): + self.product_tmpl_id.weight_dummy = 50.0 + self.product_tmpl_id._compute_weight() + self.assertEqual( + self.product_tmpl_id.weight_dummy, + self.product_tmpl_id.weight, + "Error: If set diffrent value for weight\ + Method: _compute_weight()", + ) + + def test_03_toggle_config(self): + configFalse = self.product_tmpl_id.toggle_config() + self.assertFalse( + configFalse, + "Error: If Boolean False\ + Method: toggle_config()", + ) + self.product_tmpl_id.toggle_config() + varient_value = self.product_tmpl_id._create_variant_ids() + self.assertIsNone( + varient_value, + "Error: If its return none\ + Method: create_variant_ids()", + ) + + def test_04_unlink(self): + product_config_wizard = self.ProductConfWizard.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_fuel.id}": self.value_gasoline.id, + f"__attribute_{self.attr_engine.id}": self.value_218i.id, + f"__attribute_{self.attr_color.id}": self.value_red.id, + } + ) + product_config_wizard.action_next_step() + config_session_id = self.env["product.config.session"].search( + [("product_tmpl_id", "=", self.product_tmpl_id.id)] + ) + config_session_id.unlink() + varientId = self.product_tmpl_id.product_variant_ids + boolValue = varientId.unlink() + self.assertTrue( + boolValue, + "Error: if record are not unlink\ + Method: unlink()", + ) + + def test_05_check_default_values(self): + self.attributelinefuel.default_val = (self.value_gasoline.id,) + self.attributelineengine.default_val = self.value_218d.id + with self.assertRaises(ValidationError): + self.config_product._check_default_values() + + def test_06_configure_product(self): + # configure product + self.product_tmpl_id.configure_product() + self.ProductConfWizard.action_next_step() + product_config_wizard = self.ProductConfWizard.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_fuel.id}": self.value_gasoline.id, + f"__attribute_{self.attr_engine.id}": self.value_218i.id, + f"__attribute_{self.attr_color.id}": self.value_red.id, + } + ) + wizard_action = product_config_wizard.action_next_step() + varient_id = wizard_action.get("res_id") + self.assertEqual( + varient_id, + self.product_tmpl_id.product_variant_ids.id, + "Error: If get diffrent varient Id\ + Method: action_next_step()", + ) + product_config_wizard.action_previous_step() + self.assertEqual( + product_config_wizard.state, + "select", + "Error: If get diffrent State\ + Method: action_previous_step()", + ) + # create config_step_line 1 + self.configStepLine1 = self.productConfigStepLine.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + "config_step_id": self.config_step_engine.id, + "attribute_line_ids": [ + (6, 0, [self.attributeLine1.id, self.attributeLine2.id]) + ], + } + ) + # create config_step_line 2 + self.configStepLine2 = self.productConfigStepLine.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + "config_step_id": self.config_step_body.id, + "attribute_line_ids": [(6, 0, [self.attributeLine3.id])], + } + ) + with self.assertRaises(ValidationError): + self.product_tmpl_id.write( + { + "config_step_line_ids": [ + (6, 0, [self.configStepLine1.id, self.configStepLine2.id]) + ], + } + ) + + # configure product + self.product_tmpl_id.configure_product() + product_config_wizard = self.ProductConfWizard.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_fuel.id}": self.value_gasoline.id, + f"__attribute_{self.attr_engine.id}": self.value_218i.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_color.id}": self.value_red.id, + } + ) + product_config_wizard.action_previous_step() + self.assertEqual( + product_config_wizard.state, + str(self.configStepLine1.id), + "Error: If diffrent previous state and config state\ + Method: action_previous_step()", + ) + product_config_wizard.action_next_step() + self.assertEqual( + product_config_wizard.config_session_id.config_step, + product_config_wizard.state, + "Error: If diffrent state and config_step\ + Method: action_previous_step()", + ) + product_config_wizard.action_next_step() + + def test_07_get_mako_tmpl_name(self): + # check for product_product + product_product = self._get_product_id() + mako_tmpl_vals = product_product._get_mako_tmpl_name() + self.assertEqual( + mako_tmpl_vals, + product_product.display_name, + "Error: If get display_name are different\ + Method: _get_mako_tmpl_name()", + ) + self.config_product.write({"mako_tmpl_name": "Test Configuration Product"}) + mako_tmpl_vals = product_product._get_mako_tmpl_name() + self.assertEqual( + self.config_product.mako_tmpl_name, + mako_tmpl_vals, + "Error: If Mako Template are not exists or different\ + Method: _get_mako_tmpl_name()", + ) + + def test_08_compute_product_weight(self): + product_product = self._get_product_id() + self.config_product.weight = 10 + product_product.weight_extra = 20 + product_product._compute_product_weight() + self.assertEqual( + product_product.weight, + 30, + "Error: If value are not get 30\ + Method: _compute_product_weight()", + ) + product_product.config_ok = False + product_product.weight_dummy = 50 + product_product._compute_product_weight() + self.assertEqual( + product_product.weight, + 50, + "Error: If value are not get 50\ + Method: _compute_product_weight()", + ) + + def test_09_compute_config_name(self): + product_product = self._get_product_id() + product_product.config_ok = False + product_product._compute_config_name() + self.assertEqual( + product_product.config_name, + "2 Series", + "Error: If different product config_name\ + Method: _compute_config_name()", + ) + product_product.config_ok = True + product_product._compute_config_name() + self.assertEqual( + product_product.config_name, + "2 Series", + "Error: If different product config_name\ + Method: _compute_config_name()", + ) + + def test_10_reconfigure_product(self): + self.product_tmpl_id.configure_product() + product_config_wizard = self.ProductConfWizard.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_fuel.id}": self.value_gasoline.id, + f"__attribute_{self.attr_engine.id}": self.value_218i.id, + f"__attribute_{self.attr_color.id}": self.value_red.id, + } + ) + product_config_wizard.action_next_step() + # reconfigure product + product_product = self.product_tmpl_id.product_variant_ids + product_product.reconfigure_product() + product_config_wizard = self.ProductConfWizard.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_fuel.id}": self.value_gasoline.id, + f"__attribute_{self.attr_engine.id}": self.value_218d.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_color.id}": self.value_silver.id, + } + ) + product_config_wizard.action_next_step() + value_ids = self.value_gasoline + self.value_218d + self.value_silver + # val_ids = self.value_gasoline + self.value_218i + self.value_red + # pta_val_ids = self.env["product.template.attribute.value"].search( + # [ + # ("product_tmpl_id", "=", self.product_tmpl_id.id), + # ("product_attribute_value_id", "in", value_ids.ids), + # ] + # ) + new_variant = self.product_tmpl_id.product_variant_ids.filtered( + lambda variant: variant.product_template_attribute_value_ids == value_ids + ) + self.assertFalse( + new_variant.id, + "Error: if variant id not exists\ + Method: reconfigure_product()", + ) + + def test_11_compute_product_weight_extra(self): + product_id = self.env.ref("product.product_delivery_01") + product_template_attribute_value_ids = self.env.ref( + "product.product_4_attribute_1_value_2" + ) + product_template_attribute_value_ids.write( + { + "weight_extra": 50.0, + } + ) + product_id._compute_product_weight_extra() + product_id.write( + { + "product_template_attribute_value_ids": product_template_attribute_value_ids + } + ) + self.assertEqual( + product_template_attribute_value_ids.weight_extra, + 50.0, + product_id.weight_extra, + ) + + # _compute_product_weight_extra + product_product = self._get_product_id() + productAttPrice = self.env["product.template.attribute.value"].search( + [ + ("product_tmpl_id", "=", self.config_product.id), + ("product_attribute_value_id", "=", self.value_gasoline.id), + ] + ) + productAttPrice.weight_extra = 45 + product_product._compute_product_weight_extra() + self.assertEqual( + productAttPrice.weight_extra, + product_product.weight_extra, + "Error: If weight_extra not equal\ + Method: _compute_product_weight_extra()", + ) + + def test_12_unlink(self): + product_product = self._get_product_id() + unlinkVals = product_product.unlink() + self.assertTrue( + unlinkVals, + "Error: If unlink record true\ + Method: unlink()", + ) + + def test_13_copy(self): + vals = self.config_product.copy() + self.assertEqual( + vals.name, + "2 Series (copy)", + "Error: If not equal\ + Method: copy()", + ) + self.assertTrue( + vals.attribute_line_ids, + "Error: If attribute_line_ids not exists\ + Method: copy()", + ) + + def test_14_validate_unique_config(self): + self.product_tmpl_id.write( + { + "attribute_value_line_ids": [ + ( + 0, + 0, + { + "product_tmpl_id": self.product_tmpl_id.id, + "value_id": self.value_gasoline.id, + "value_ids": [(6, 0, [self.value_218i.id])], + }, + ) + ] + } + ) + with self.assertRaises(ValidationError): + self.product_tmpl_id.write( + { + "attribute_value_line_ids": [ + ( + 0, + 0, + { + "product_tmpl_id": self.product_tmpl_id.id, + "value_id": self.value_gasoline.id, + "value_ids": [(6, 0, [self.value_218i.id])], + }, + ) + ] + } + ) + + def test_15_check_attr_value_ids(self): + self.product_tmpl_id.write( + { + "attribute_value_line_ids": [ + ( + 0, + 0, + { + "product_tmpl_id": self.product_tmpl_id.id, + "value_id": self.value_gasoline.id, + "value_ids": [(6, 0, [self.value_gasoline.id])], + }, + ) + ] + } + ) + self.product_tmpl_id.write( + { + "attribute_value_line_ids": [ + ( + 0, + 0, + { + "product_tmpl_id": self.product_tmpl_id.id, + "value_id": self.value_diesel.id, + "value_ids": [(6, 0, [self.value_diesel.id])], + }, + ) + ] + } + ) + self.product_tmpl_id.write( + { + "attribute_value_line_ids": [ + ( + 0, + 0, + { + "product_tmpl_id": self.product_tmpl_id.id, + "value_id": self.value_218i.id, + "value_ids": [(6, 0, [self.value_218i.id])], + }, + ) + ] + } + ) + self.product_tmpl_id.write( + { + "attribute_value_line_ids": [ + ( + 0, + 0, + { + "product_tmpl_id": self.product_tmpl_id.id, + "value_id": self.value_220i.id, + "value_ids": [(6, 0, [self.value_220i.id])], + }, + ) + ] + } + ) + self.product_tmpl_id.write( + { + "attribute_value_line_ids": [ + ( + 0, + 0, + { + "product_tmpl_id": self.product_tmpl_id.id, + "value_id": self.value_218d.id, + "value_ids": [(6, 0, [self.value_218d.id])], + }, + ) + ] + } + ) + self.product_tmpl_id.write( + { + "attribute_value_line_ids": [ + ( + 0, + 0, + { + "product_tmpl_id": self.product_tmpl_id.id, + "value_id": self.value_220d.id, + "value_ids": [(6, 0, [self.value_220d.id])], + }, + ) + ] + } + ) + self.product_tmpl_id.write( + { + "attribute_value_line_ids": [ + ( + 0, + 0, + { + "product_tmpl_id": self.product_tmpl_id.id, + "value_id": self.value_red.id, + "value_ids": [(6, 0, [self.value_red.id])], + }, + ) + ] + } + ) + self.product_tmpl_id.write( + { + "attribute_value_line_ids": [ + ( + 0, + 0, + { + "product_tmpl_id": self.product_tmpl_id.id, + "value_id": self.value_silver.id, + "value_ids": [(6, 0, [self.value_silver.id])], + }, + ) + ] + } + ) + with self.assertRaises(ValidationError): + self.product_tmpl_id.write( + { + "attribute_value_line_ids": [ + ( + 0, + 0, + { + "product_tmpl_id": self.product_tmpl_id.id, + "value_id": self.value_rims_378.id, + "value_ids": [(6, 0, [self.value_rims_378.id])], + }, + ) + ] + } + ) + + def test_16_check_duplicate_product(self): + self.product_tmpl_id.configure_product() + product_config_wizard = self.ProductConfWizard.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_fuel.id}": self.value_gasoline.id, + f"__attribute_{self.attr_engine.id}": self.value_218i.id, + f"__attribute_{self.attr_color.id}": self.value_red.id, + } + ) + product_config_wizard.action_next_step() + val_ids = self.value_gasoline + self.value_218i + self.value_red + pta_val_ids = self.env["product.template.attribute.value"].search( + [ + ("product_tmpl_id", "=", self.product_tmpl_id.id), + ("product_attribute_value_id", "in", val_ids.ids), + ] + ) + with self.assertRaises(ValidationError): + self.env["product.product"].create( + { + "name": "Test Configuration", + "product_tmpl_id": self.product_tmpl_id.id, + "product_template_attribute_value_ids": [(6, 0, pta_val_ids.ids)], + } + ) + + def test_17_fields_view_get(self): + product_product = self._get_product_id() + product_product.with_context(default_config_ok=True).get_view() + + def test_18_get_conversions_dict(self): + product_product = self._get_product_id() + product_product._get_conversions_dict() + + def test_19_compute_product_variant_count(self): + self.product_tmpl_id = self.env["product.template"].create( + { + "name": "Test Configuration", + "config_ok": True, + "type": "consu", + "categ_id": self.product_category.id, + } + ) + product_variant_count = self.product_tmpl_id.product_variant_count + self.assertEqual( + product_variant_count, + 1, + "Error: If not equal\ + Method: _compute_product_variant_count()", + ) + + def test_20_get_config_name(self): + product_product = self._get_product_id() + product_product._get_config_name() + self.assertTrue( + product_product.name, + "Error: If value False\ + Method: _get_config_name()", + ) + + def test_21_search_product_weight(self): + product_product = self._get_product_id() + operator = "and" + value = 10 + search_product_weight = product_product._search_product_weight(operator, value) + self.assertTrue( + search_product_weight, + "Error: If value False\ + Method: _search_product_weight()", + ) + + def test_22_search_weight(self): + operator = "and" + value = 10 + search_weight = self.product_tmpl_id._search_weight(operator, value) + self.assertTrue( + search_weight, + "Error: If value False\ + Method: _search_weight()", + ) + + def test_23_check_config_line_domain(self): + product_config_line = self.env.ref( + "product_configurator.product_config_line_218_lines" + ) + with self.assertRaises(ValidationError): + self.env["product.template"].create( + { + "name": "template_test", + "config_line_ids": product_config_line, + } + ) diff --git a/product_configurator/tests/test_product_attribute.py b/product_configurator/tests/test_product_attribute.py new file mode 100644 index 0000000000..bb875c4674 --- /dev/null +++ b/product_configurator/tests/test_product_attribute.py @@ -0,0 +1,182 @@ +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + + +class ProductAttributes(TransactionCase): + def setUp(self): + super().setUp() + self.productAttributeLine = self.env["product.template.attribute.line"] + self.ProductAttributeFuel = self.env.ref( + "product_configurator.product_attribute_fuel" + ) + self.ProductAttributeLineFuel = self.env.ref( + "product_configurator.product_attribute_line_2_series_fuel" + ) + self.ProductTemplate = self.env.ref("product_configurator.bmw_2_series") + self.product_category = self.env.ref("product.product_category_5") + self.ProductAttributePrice = self.env["product.template.attribute.value"] + self.attr_fuel = self.env.ref("product_configurator.product_attribute_fuel") + self.attr_engine = self.env.ref("product_configurator.product_attribute_engine") + self.value_diesel = self.env.ref( + "product_configurator.product_attribute_value_diesel" + ) + self.value_218i = self.env.ref( + "product_configurator.product_attribute_value_218i" + ) + self.value_gasoline = self.env.ref( + "product_configurator.product_attribute_value_gasoline" + ) + self.ProductAttributeValueFuel = self.value_gasoline.attribute_id.id + + def test_01_onchange_custome_type(self): + self.ProductAttributeFuel.min_val = 20 + self.ProductAttributeFuel.max_val = 30 + self.ProductAttributeFuel.custom_type = "char" + self.ProductAttributeFuel.onchange_custom_type() + self.assertEqual(self.ProductAttributeFuel.min_val, 0, "Min value is not False") + self.assertEqual(self.ProductAttributeFuel.max_val, 0, "Max value is not False") + + self.ProductAttributeFuel.min_val = 20 + self.ProductAttributeFuel.max_val = 30 + self.ProductAttributeFuel.custom_type = "integer" + self.ProductAttributeFuel.onchange_custom_type() + self.assertEqual( + self.ProductAttributeFuel.min_val, + 20, + "Min value is not equal to existing min value", + ) + self.assertEqual( + self.ProductAttributeFuel.max_val, + 30, + "Max value is not equal to existing max value", + ) + + self.ProductAttributeFuel.custom_type = "float" + self.ProductAttributeFuel.onchange_custom_type() + self.assertEqual( + self.ProductAttributeFuel.min_val, + 20, + "Min value is equal to existing min value \ + when type is changed to integer to float", + ) + self.assertEqual( + self.ProductAttributeFuel.max_val, + 30, + "Max value is equal to existing max value \ + when type is changed to integer to float", + ) + self.ProductAttributeFuel.custom_type = "binary" + self.ProductAttributeFuel.onchange_custom_type() + self.assertFalse( + self.ProductAttributeFuel.search_ok, + "Error: if search true\ + Method: onchange_custom_type()", + ) + + def test_02_onchange_val_custom(self): + self.ProductAttributeFuel.val_custom = False + self.ProductAttributeFuel.custom_type = "integer" + self.ProductAttributeFuel.onchange_val_custom_field() + self.assertFalse( + self.ProductAttributeFuel.custom_type, "custom_type is not False" + ) + + def test_03_check_searchable_field(self): + self.ProductAttributeFuel.custom_type = "binary" + with self.assertRaises(ValidationError): + self.ProductAttributeFuel.search_ok = True + + def test_04_validate_custom_val(self): + self.ProductAttributeFuel.write({"max_val": 20, "min_val": 10}) + self.ProductAttributeFuel.custom_type = "integer" + with self.assertRaises(ValidationError): + self.ProductAttributeFuel.validate_custom_val(5) + + self.ProductAttributeFuel.write({"max_val": 0, "min_val": 10}) + self.ProductAttributeFuel.custom_type = "integer" + with self.assertRaises(ValidationError): + self.ProductAttributeFuel.validate_custom_val(5) + + self.ProductAttributeFuel.write({"min_val": 0, "max_val": 20}) + self.ProductAttributeFuel.custom_type = "integer" + with self.assertRaises(ValidationError): + self.ProductAttributeFuel.validate_custom_val(25) + + def test_05_check_constraint_min_max_value(self): + self.ProductAttributeFuel.custom_type = "integer" + with self.assertRaises(ValidationError): + self.ProductAttributeFuel.write({"max_val": 10, "min_val": 20}) + + def test_06_onchange_attribute(self): + with self.env.do_in_onchange(): + self.ProductAttributeLineFuel.onchange_attribute() + self.assertFalse( + self.ProductAttributeLineFuel.value_ids, "value_ids is not False" + ) + self.assertTrue( + self.ProductAttributeLineFuel.required, "required not exsits value" + ) + self.ProductAttributeLineFuel.multi = True + self.assertTrue( + self.ProductAttributeLineFuel.multi, "multi not exsits value" + ) + self.ProductAttributeLineFuel.custom = True + self.assertTrue( + self.ProductAttributeLineFuel.custom, "custom not exsits value" + ) + + def test_07_check_default_values(self): + with self.assertRaises(ValidationError): + self.ProductAttributeLineFuel.default_val = self.value_218i.id + + def test_08_copy_attribute(self): + copyAttribute = self.ProductAttributeFuel.copy() + self.assertEqual( + copyAttribute.name, + "Fuel (copy)", + "Error: If not copy attribute\ + Method: copy()", + ) + + def test_09_compute_get_value_id(self): + attrvalline = self.env["product.attribute.value.line"].create( + { + "product_tmpl_id": self.ProductTemplate.id, + "value_id": self.value_gasoline.id, + } + ) + self.assertTrue( + attrvalline.product_value_ids, + "Error: If product_value_ids not exists\ + Method: _compute_get_value_id()", + ) + + def test_10_validate_configuration(self): + with self.assertRaises(ValidationError): + self.env["product.attribute.value.line"].create( + { + "product_tmpl_id": self.ProductTemplate.id, + "value_id": self.value_diesel.id, + "value_ids": [(6, 0, [self.value_218i.id])], + } + ) + + def test_11_copy(self): + default = {} + productattribute = self.value_gasoline.copy(default) + self.assertEqual( + productattribute.name, + self.value_gasoline.name + " (copy)", + "Error: If not equal productattribute name\ + Method: copy()", + ) + + def test_12_onchange_values(self): + productattributeline = self.env["product.template.attribute.line"] + productattributeline.onchange_values() + self.assertEqual( + productattributeline.default_val, + productattributeline.value_ids, + "Error: If default_val not exists\ + Method: onchange_values()", + ) diff --git a/product_configurator/tests/test_product_config.py b/product_configurator/tests/test_product_config.py new file mode 100644 index 0000000000..bd00a7113a --- /dev/null +++ b/product_configurator/tests/test_product_config.py @@ -0,0 +1,711 @@ +from odoo.exceptions import UserError, ValidationError + +from ..tests.test_product_configurator_test_cases import ProductConfiguratorTestCases + + +class ProductConfig(ProductConfiguratorTestCases): + def setUp(self): + super().setUp() + self.productConfWizard = self.env["product.configurator"] + self.productTemplate = self.env["product.template"] + self.productAttribute = self.env["product.attribute"] + self.productAttributeVals = self.env["product.attribute.value"] + self.productAttributeLine = self.env["product.template.attribute.line"] + self.productConfigSession = self.env["product.config.session"] + self.productConfigDomain = self.env["product.config.domain"] + self.config_product = self.env.ref("product_configurator.bmw_2_series") + self.attr_engine = self.env.ref("product_configurator.product_attribute_engine") + self.config_step_engine = self.env.ref( + "product_configurator.config_step_engine" + ) + self.config_product_1 = self.env.ref( + "product_configurator.product_config_line_gasoline_engines" + ) + self.config_product_2 = self.env.ref( + "product_configurator.2_series_config_step_body" + ) + # domain + self.domain_gasolin = self.env.ref( + "product_configurator.product_config_domain_gasoline" + ) + self.domain_engine = self.env.ref( + "product_configurator.product_config_domain_diesel" + ) + self.config_image_red = self.env.ref("product_configurator.config_image_1") + # value + self.value_gasoline = self.env.ref( + "product_configurator.product_attribute_value_gasoline" + ) + self.value_diesel = self.env.ref( + "product_configurator.product_attribute_value_diesel" + ) + self.value_red = self.env.ref( + "product_configurator.product_attribute_value_red" + ) + # config_step + self.config_step_engine = self.env.ref( + "product_configurator.config_step_engine" + ) + self.attribute_line = self.env.ref( + "product_configurator.product_attribute_line_2_series_engine" + ) + self.value_silver = self.env.ref( + "product_configurator.product_attribute_value_silver" + ) + self.value_rims_387 = self.env.ref( + "product_configurator.product_attribute_value_rims_387" + ) + # attribute line + self.attribute_line_2_series_rims = self.env.ref( + "product_configurator.product_attribute_line_2_series_rims" + ) + self.attribute_line_2_series_tapistry = self.env.ref( + "product_configurator.product_attribute_line_2_series_tapistry" + ) + self.attribute_value_tapistry_oyster_black = self.env.ref( + "product_configurator." + "product_attribute_value_tapistry_oyster_black" + ) + self.attribute_line_2_series_transmission = self.env.ref( + "product_configurator.product_attribute_line_2_series_transmission" + ) + + # attribute value + self.attribute_rims = self.env.ref( + "product_configurator.product_attribute_rims" + ) + self.attribute_tapistry = self.env.ref( + "product_configurator.product_attribute_tapistry" + ) + self.attribute_transmission = self.env.ref( + "product_configurator.product_attribute_transmission" + ) + + # session id + self.session_id = self.productConfigSession.create( + { + "product_tmpl_id": self.config_product.id, + "value_ids": [ + ( + 6, + 0, + [ + self.value_gasoline.id, + self.value_transmission.id, + self.value_red.id, + ], + ) + ], + "user_id": self.env.user.id, + } + ) + # ir attachment + self.irAttachement = self.env["ir.attachment"].create( + { + "name": "Test attachement", + "datas": "bWlncmF0aW9uIHRlc3Q=", + } + ) + + # configure product + self._configure_product_nxt_step() + self.config_session = self.productConfigSession.search( + [("product_tmpl_id", "=", self.config_product.id)] + ) + + # create product template + self.product_tmpl_id = self.productTemplate.create({"name": "Coca-Cola"}) + # create attribute 1 + self.attribute_1 = self.productAttribute.create( + { + "name": "Color", + } + ) + # create attribute 2 + self.attribute_2 = self.productAttribute.create( + { + "name": "Flavour", + } + ) + + # create attribute value 1 + self.attribute_vals_1 = self.productAttributeVals.create( + { + "name": "Orange", + "attribute_id": self.attribute_1.id, + } + ) + # create attribute value 2 + self.attribute_vals_2 = self.productAttributeVals.create( + { + "name": "Balck", + "attribute_id": self.attribute_1.id, + } + ) + # create attribute value 3 + self.attribute_vals_3 = self.productAttributeVals.create( + { + "name": "Coke", + "attribute_id": self.attribute_2.id, + } + ) + # create attribute value 4 + self.attribute_vals_4 = self.productAttributeVals.create( + { + "name": "Mango", + "attribute_id": self.attribute_2.id, + } + ) + + # TODO :: Left to take review of code + def test_00_check_value_attributes(self): + with self.assertRaises(ValidationError): + self.config_product_1.write( + {"value_ids": [(6, 0, [self.value_gasoline.id])]} + ) + + def test_01_check_config_step(self): + with self.assertRaises(ValidationError): + self.config_product_2.config_step_id = 4 + + def test_02_get_trans_implied(self): + self.domain_gasolin.write({"implied_ids": [(6, 0, [self.domain_engine.id])]}) + trans_implied_ids = self.domain_gasolin.trans_implied_ids.ids + self.assertEqual( + trans_implied_ids[-1], + self.domain_engine.id, + "Error: If value not exists\ + Method: _get_trans_implied()", + ) + + def test_03_check_config_step(self): + with self.assertRaises(ValidationError): + self.env["product.config.step.line"].create( + { + "product_tmpl_id": self.config_product.id, + "config_step_id": self.config_step_engine.id, + "attribute_line_ids": [(6, 0, [self.attribute_line.id])], + } + ) + + def test_04_compute_cfg_price(self): + # check for _compute_cfg_price + price = self.config_product.list_price + price += self.value_220i.product_id.lst_price + price += self.value_model_sport_line.product_id.lst_price + price += self.value_transmission.product_id.lst_price + price += self.value_options_2.product_id.lst_price + self.assertEqual( + self.session_id.price, + price, + "Error: If different session price and list_price\ + Method: _compute_cfg_price", + ) + + def test_05_get_custom_vals_dict(self): + # check for _get_custom_vals_dict + productConfigSessionCustVals = self.env[ + "product.config.session.custom.value" + ].create( + {"cfg_session_id": self.session_id.id, "attribute_id": self.attr_fuel.id} + ) + # check for custom type Int + self.attr_fuel.custom_type = "integer" + productConfigSessionCustVals.update({"value": 154}) + checkIntval = self.session_id._get_custom_vals_dict() + attr_id = productConfigSessionCustVals.attribute_id.id + self.assertEqual( + checkIntval.get(attr_id), + 154, + "Error: If Not Integer value or False\ + Method: _get_custom_vals_dict()", + ) + # check for custom type Float + self.attr_fuel.custom_type = "float" + productConfigSessionCustVals.update({"value": 94.5}) + checkFloatval = self.session_id._get_custom_vals_dict() + attr_id = productConfigSessionCustVals.attribute_id.id + self.assertEqual( + checkFloatval.get(attr_id), + 94.5, + "Error: If Not Float value or False\ + Method: _get_custom_vals_dict()", + ) + # check for custom type Binary + self.attr_color.custom_type = "binary" + productConfigSessionCustVals1 = self.env[ + "product.config.session.custom.value" + ].create( + { + "cfg_session_id": self.session_id.id, + "attribute_id": self.attr_color.id, + "attachment_ids": [(6, 0, [self.irAttachement.id])], + } + ) + checkBinaryval = self.session_id._get_custom_vals_dict() + attr_id = productConfigSessionCustVals1.attribute_id.id + self.assertEqual( + checkBinaryval.get(attr_id), + productConfigSessionCustVals1.attachment_ids, + "Error: If Not attachement\ + Method: _get_custom_vals_dict()", + ) + + def test_06_compute_config_step_name(self): + self.config_session._compute_config_step_name() + self.assertTrue( + self.config_session.config_step_name, + "Error: If not config step name\ + Method: _compute_config_step_name()", + ) + self.config_session._compute_config_step_name() + self.assertEqual( + self.config_session.config_step_name, + "Extras", + "Error: If not equal config_step_name and config_step\ + Method: _compute_config_step_name()", + ) + session = self.productConfigSession.create( + { + "product_tmpl_id": self.config_product.id, + "value_ids": [ + (6, 0, [self.value_gasoline.id, self.value_transmission.id]) + ], + "user_id": self.env.user.id, + } + ) + session._compute_config_step_name() + self.assertFalse( + session.config_step_name, + "Error: If config_step_name not False\ + Method: _compute_config_step_name()", + ) + + def test_07_search_variant(self): + with self.assertRaises(ValidationError): + self.env["product.config.session"].search_variant() + + # check for search duplicate variant + variant_id = self.config_product.product_variant_ids + checkSearchvarient = self.config_session.search_variant() + self.assertEqual( + checkSearchvarient, + variant_id, + "Error: If Not Equal Variant or False\ + Method: search_variant()", + ) + + def test_08_check_custom_type(self): + # check for check_custom_type + with self.assertRaises(ValidationError): + self.env["product.config.session.custom.value"].create( + { + "attribute_id": self.value_silver.attribute_id.id, + "cfg_session_id": self.config_session.id, + "value": "Test", + "attachment_ids": [(6, 0, [self.irAttachement.id])], + } + ) + + self.attr_color.custom_type = "binary" + with self.assertRaises(ValidationError): + self.env["product.config.session.custom.value"].create( + { + "attribute_id": self.value_silver.attribute_id.id, + "cfg_session_id": self.config_session.id, + "value": "Test", + "attachment_ids": [(6, 0, [self.irAttachement.id])], + } + ) + + def test_09_create_get_variant(self): + # configure new product to check for search not dublicate variant + self.attributeLine1 = self.productAttributeLine.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + "attribute_id": self.attribute_1.id, + "value_ids": [ + (6, 0, [self.attribute_vals_1.id, self.attribute_vals_2.id]) + ], + } + ) + # create attribute line 2 + self.attributeLine2 = self.productAttributeLine.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + "attribute_id": self.attribute_2.id, + "value_ids": [ + (6, 0, [self.attribute_vals_3.id, self.attribute_vals_4.id]) + ], + } + ) + self.product_tmpl_id.write( + { + "attribute_line_ids": [ + (6, 0, [self.attributeLine1.id, self.attributeLine2.id]) + ], + } + ) + self.product_tmpl_id.configure_product() + self.productConfWizard.action_next_step() + product_config_wizard = self.productConfWizard.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attribute_1.id}": self.attribute_vals_1.id, + f"__attribute_{self.attribute_2.id}": self.attribute_vals_3.id, + } + ) + product_config_wizard.action_next_step() + config_session_1 = self.productConfigSession.search( + [("product_tmpl_id", "=", self.product_tmpl_id.id)] + ) + createVarientId = config_session_1.create_get_variant() + self.assertEqual( + createVarientId.name, + self.product_tmpl_id.name, + "Error: If Not Equal variant name\ + Method: search_variant()", + ) + self.attributeLine1.custom = True + self.env["product.config.session.custom.value"].create( + { + "cfg_session_id": config_session_1.id, + "attribute_id": self.attribute_1.id, + "value": "Coke", + } + ) + config_session_1.create_get_variant() + + def test_10_check_value_ids(self): + with self.assertRaises(ValidationError): + self.config_image_red.write( + {"value_ids": [(6, 0, [self.value_gasoline.id, self.value_diesel.id])]} + ) + + def test_11_unique_attribute(self): + with self.assertRaises(ValidationError): + self.env["product.config.session.custom.value"].create( + { + "cfg_session_id": self.config_session.id, + "attribute_id": self.attr_engine.id, + "value": "1234", + } + ) + self.env["product.config.session.custom.value"].create( + { + "cfg_session_id": self.config_session.id, + "attribute_id": self.attr_engine.id, + "value": "1234", + } + ) + + def test_12_get_cfg_weight(self): + self.env["product.template.attribute.value"].create( + { + "product_tmpl_id": self.config_product.id, + "product_attribute_value_id": self.value_red.id, + "weight_extra": 20.0, + } + ) + self.config_product.weight = 20 + weightVal = self.config_session.get_cfg_weight() + self.assertEqual( + weightVal, + 40.0, + "Error: If Value are not equal\ + Method: get_cfg_weight()", + ) + # check for config weight + self.assertEqual( + self.config_session.weight, + 40.0, + "Error: If config weight are not equal\ + Method: _compute_cfg_weight()", + ) + + def test_13_update_session_configuration_value(self): + # configure new product to check for search not dublicate variant + self.custom_vals = self.productConfigSession.get_custom_value_id() + self.attributeLine1 = self.productAttributeLine.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + "attribute_id": self.attribute_1.id, + "value_ids": [ + (6, 0, [self.attribute_vals_1.id, self.attribute_vals_2.id]) + ], + "custom": True, + "required": True, + } + ) + # create attribute line 2 + self.attributeLine2 = self.productAttributeLine.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + "attribute_id": self.attribute_2.id, + "value_ids": [ + (6, 0, [self.attribute_vals_3.id, self.attribute_vals_4.id]) + ], + "custom": True, + "required": True, + } + ) + self.product_tmpl_id.write( + { + "attribute_line_ids": [ + (6, 0, [self.attributeLine1.id, self.attributeLine2.id]) + ], + } + ) + self.attribute_1.custom_type = "binary" + self.product_tmpl_id.configure_product() + self.productConfWizard.action_next_step() + product_config_wizard = self.productConfWizard.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attribute_1.id}": self.custom_vals.id, + f"__custom_{self.attribute_1.id}": self.irAttachement.id, + f"__attribute_{self.attribute_1.id}": self.custom_vals.id, + f"__custom_{self.attribute_1.id}": "Test", + } + ) + product_config_wizard.action_next_step() + + def test_14_get_cfg_price(self): + self.env["product.template.attribute.value"].create( + { + "product_tmpl_id": self.config_product.id, + "product_attribute_value_id": self.value_red.id, + "weight_extra": 20.0, + "price_extra": 20.0, + } + ) + price = self.config_product.list_price + price += self.value_220i.product_id.lst_price + price += self.value_model_sport_line.product_id.lst_price + price += self.value_transmission.product_id.lst_price + price += self.value_options_2.product_id.lst_price + price_extra_val = self.session_id.get_cfg_price() + self.assertEqual( + price_extra_val, + price + 20, + "Error: If not equal price extra\ + Method: get_cfg_price()", + ) + + def test_15_get_next_step(self): + self.session_id.get_next_step(state=None) + self.session_id.get_next_step(state="draft") + with self.assertRaises(UserError): + self.productConfigSession.get_next_step( + state="draft", value_ids=False, custom_value_ids=False + ) + + def test_16_get_all_step_lines(self): + step_line_value_1 = self.productConfigSession.get_all_step_lines() + self.assertFalse( + step_line_value_1, + "Error: If return True\ + Method: get_all_step_lines()", + ) + step_line_value_2 = self.session_id.get_all_step_lines() + self.assertTrue( + step_line_value_2, + "Error: If return True\ + Method: get_all_step_lines()", + ) + + def test_17_custom_value_validate_configuration(self): + self.custom_vals = self.productConfigSession.get_custom_value_id() + self.attributeLine1 = self.productAttributeLine.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + "attribute_id": self.attribute_1.id, + "value_ids": [ + (6, 0, [self.attribute_vals_1.id, self.attribute_vals_2.id]) + ], + "custom": True, + "required": True, + } + ) + # create attribute line 2 + self.attributeLine2 = self.productAttributeLine.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + "attribute_id": self.attribute_2.id, + "value_ids": [ + (6, 0, [self.attribute_vals_3.id, self.attribute_vals_4.id]) + ], + "custom": True, + "required": True, + } + ) + self.product_tmpl_id.write( + { + "attribute_line_ids": [ + (6, 0, [self.attributeLine1.id, self.attributeLine2.id]) + ], + } + ) + self.attribute_1.custom_type = "binary" + self.product_tmpl_id.configure_product() + self.productConfWizard.action_next_step() + product_config_wizard = self.productConfWizard.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attribute_1.id}": self.custom_vals.id, + f"__custom_{self.attribute_1.id}": self.irAttachement.id, + f"__attribute_{self.attribute_1.id}": self.custom_vals.id, + f"__custom_{self.attribute_1.id}": "Test", + } + ) + self.attributeLine1.custom = False + self.attributeLine2.custom = False + with self.assertRaises(ValidationError): + self.product_tmpl_id.configure_product() + + def test_18_onchange_attribute(self): + # create domain + self.productConfigDomainId = self.env["product.config.domain"].create( + {"name": "restriction 1"} + ) + self.productConfigDomainId.compute_domain() + # create attribute value line 1 + self.env["product.config.domain.line"].create( + { + "domain_id": self.productConfigDomainId.id, + "attribute_id": self.attr_fuel.id, + "condition": "in", + "value_ids": [(6, 0, [self.value_gasoline.id])], + "operator": "and", + } + ) + self.env["product.config.domain.line"].create( + { + "domain_id": self.productConfigDomainId.id, + "attribute_id": self.attr_color.id, + "condition": "in", + "value_ids": [(6, 0, [self.value_red.id])], + "operator": "and", + } + ) + self.attributeLine1 = self.productAttributeLine.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + "attribute_id": self.attribute_1.id, + "value_ids": [ + (6, 0, [self.attribute_vals_1.id, self.attribute_vals_2.id]) + ], + "required": True, + } + ) + # create attribute line 2 + self.attributeLine2 = self.productAttributeLine.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + "attribute_id": self.attribute_2.id, + "value_ids": [ + (6, 0, [self.attribute_vals_3.id, self.attribute_vals_4.id]) + ], + "required": True, + } + ) + self.product_tmpl_id.write( + { + "attribute_line_ids": [ + (6, 0, [self.attributeLine1.id, self.attributeLine2.id]) + ], + } + ) + self.productConfigDomainId.compute_domain() + # create attribute value line 1 + config_line = self.env["product.config.line"].create( + { + "product_tmpl_id": self.product_tmpl_id.id, + "attribute_line_id": self.attributeLine1.id, + "value_ids": [ + (6, 0, [self.attribute_vals_1.id, self.attribute_vals_2.id]) + ], + "domain_id": self.productConfigDomainId.id, + } + ) + with self.assertRaises(ValidationError): + config_line.onchange_attribute() + + self.assertFalse( + config_line.value_ids, + "Error: If value_ids True\ + Method: onchange_attribute()", + ) + + def test_19_eval(self): + self.attr_color.custom_type = "binary" + productConfigSessionCustVals1 = self.env[ + "product.config.session.custom.value" + ].create( + { + "cfg_session_id": self.session_id.id, + "attribute_id": self.attr_color.id, + "attachment_ids": [(6, 0, [self.irAttachement.id])], + } + ) + checkBinary = productConfigSessionCustVals1.eval() + self.assertTrue( + checkBinary, + "Error: If value False\ + Method: eval()", + ) + + productConfigSessionCustVals = self.env[ + "product.config.session.custom.value" + ].create( + {"cfg_session_id": self.session_id.id, "attribute_id": self.attr_fuel.id} + ) + self.attr_fuel.custom_type = "integer" + productConfigSessionCustVals.update({"value": 154}) + checkIntval = productConfigSessionCustVals.eval() + self.assertEqual( + 154, + checkIntval, + "Error: If Value not equal\ + Method: eval()", + ) + + self.attr_fuel.custom_type = "float" + productConfigSessionCustVals.update({"value": 15.4}) + checkfloat = productConfigSessionCustVals.eval() + self.assertEqual( + 15.4, + checkfloat, + "Error: If Value not equal\ + Method: eval()", + ) + + def test_20_values_available(self): + check_available_val_ids = ( + self.value_gasoline + self.value_218i + self.value_sport_line + ).ids + product_tmpl_id = self.config_product.id + values_ids = [self.value_diesel.id] + available_value_ids = self.productConfigSession.values_available( + check_available_val_ids, values_ids, {}, product_tmpl_id + ) + self.assertNotIn( + self.value_sport_line.id, + available_value_ids, + "Error: If value exists\ + Method: values_available()", + ) diff --git a/product_configurator/tests/test_product_configurator_test_cases.py b/product_configurator/tests/test_product_configurator_test_cases.py new file mode 100644 index 0000000000..e8c9c99bf4 --- /dev/null +++ b/product_configurator/tests/test_product_configurator_test_cases.py @@ -0,0 +1,121 @@ +from odoo.tests.common import TransactionCase + + +class ProductConfiguratorTestCases(TransactionCase): + def setUp(self): + super().setUp() + + self.ProductConfWizard = self.env["product.configurator"] + self.config_product = self.env.ref("product_configurator.bmw_2_series") + self.product_category = self.env.ref("product.product_category_5") + + # attributes + self.attr_fuel = self.env.ref("product_configurator.product_attribute_fuel") + self.attr_engine = self.env.ref("product_configurator.product_attribute_engine") + self.attr_color = self.env.ref("product_configurator.product_attribute_color") + self.attr_rims = self.env.ref("product_configurator.product_attribute_rims") + self.attr_model_line = self.env.ref( + "product_configurator.product_attribute_model_line" + ) + self.attr_tapistry = self.env.ref( + "product_configurator.product_attribute_tapistry" + ) + self.attr_transmission = self.env.ref( + "product_configurator.product_attribute_transmission" + ) + self.attr_options = self.env.ref( + "product_configurator.product_attribute_options" + ) + + # values + self.value_gasoline = self.env.ref( + "product_configurator.product_attribute_value_gasoline" + ) + self.value_218i = self.env.ref( + "product_configurator.product_attribute_value_218i" + ) + self.value_220i = self.env.ref( + "product_configurator.product_attribute_value_220i" + ) + self.value_red = self.env.ref( + "product_configurator.product_attribute_value_red" + ) + self.value_rims_378 = self.env.ref( + "product_configurator.product_attribute_value_rims_378" + ) + self.value_sport_line = self.env.ref( + "product_configurator.product_attribute_value_sport_line" + ) + self.value_model_sport_line = self.env.ref( + "product_configurator.product_attribute_value_model_sport_line" + ) + self.value_tapistry = self.env.ref( + "product_configurator.product_attribute_value_tapistry" + "_oyster_black" + ) + self.value_transmission = self.env.ref( + "product_configurator.product_attribute_value_steptronic" + ) + self.value_options_1 = self.env.ref( + "product_configurator.product_attribute_value_smoker_package" + ) + self.value_options_2 = self.env.ref( + "product_configurator.product_attribute_value_sunroof" + ) + + def _configure_product_nxt_step(self): + product_config_wizard = self.ProductConfWizard.create( + { + "product_tmpl_id": self.config_product.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_fuel.id}": self.value_gasoline.id, + f"__attribute_{self.attr_engine.id}": self.value_218i.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_color.id}": self.value_red.id, + f"__attribute_{self.attr_rims.id}": self.value_rims_378.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_model_line.id}": self.value_sport_line.id, + } + ) + product_config_wizard.action_previous_step() + product_config_wizard.action_previous_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_engine.id}": self.value_220i.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_model_line.id}": self.value_model_sport_line.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_tapistry.id}": self.value_tapistry.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_transmission.id}": self.value_transmission.id, + f"__attribute_{self.attr_options.id}": [ + [6, 0, [self.value_options_2.id]] + ], + } + ) + + return product_config_wizard.action_next_step() diff --git a/product_configurator/tests/test_wizard.py b/product_configurator/tests/test_wizard.py new file mode 100644 index 0000000000..b18805e451 --- /dev/null +++ b/product_configurator/tests/test_wizard.py @@ -0,0 +1,592 @@ +from odoo.exceptions import UserError + +from ..tests.test_product_configurator_test_cases import ProductConfiguratorTestCases + + +class ConfigurationWizard(ProductConfiguratorTestCases): + def setUp(self): + super().setUp() + self.productTemplate = self.env["product.template"] + self.productAttributeLine = self.env["product.template.attribute.line"] + self.productConfigStepLine = self.env["product.config.step.line"] + self.productConfigSession = self.env["product.config.session"] + self.product_category = self.env.ref("product.product_category_5") + self.attr_line_fuel = self.env.ref( + "product_configurator.product_attribute_line_2_series_fuel" + ) + self.attr_line_engine = self.env.ref( + "product_configurator.product_attribute_line_2_series_engine" + ) + self.value_diesel = self.env.ref( + "product_configurator.product_attribute_value_diesel" + ) + self.value_218d = self.env.ref( + "product_configurator.product_attribute_value_218d" + ) + self.value_220d = self.env.ref( + "product_configurator.product_attribute_value_220d" + ) + self.value_silver = self.env.ref( + "product_configurator.product_attribute_value_silver" + ) + self.config_step_engine = self.env.ref( + "product_configurator.config_step_engine" + ) + self.config_step_body = self.env.ref("product_configurator.config_step_body") + self.product_tmpl_id = self.env["product.template"].create( + { + "name": "Test Configuration", + "config_ok": True, + "type": "consu", + "categ_id": self.product_category.id, + } + ) + self.custom_vals = self.productConfigSession.get_custom_value_id() + self.cfg_tmpl = self.env.ref("product_configurator.bmw_2_series") + + attribute_vals = self.cfg_tmpl.attribute_line_ids.mapped("value_ids") + self.attr_vals = attribute_vals + + self.attr_val_ext_ids = { + v: k for k, v in attribute_vals.get_external_id().items() + } + + def _check_wizard_nxt_step(self): + self.ProductConfWizard.action_next_step() + product_config_wizard = self.ProductConfWizard.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + } + ) + # create attribute line 1 + self.attributeLine1 = self.productAttributeLine.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + "attribute_id": self.attr_fuel.id, + "value_ids": [(6, 0, [self.value_gasoline.id, self.value_diesel.id])], + "required": True, + } + ) + # create attribute line 2 + self.attributeLine2 = self.productAttributeLine.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + "attribute_id": self.attr_engine.id, + "value_ids": [(6, 0, [self.value_218i.id, self.value_220i.id])], + "required": True, + } + ) + # create attribute line 2 + self.attributeLine3 = self.productAttributeLine.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + "attribute_id": self.attr_engine.id, + "value_ids": [(6, 0, [self.value_218d.id, self.value_220d.id])], + "required": True, + } + ) + # configure product creating config step + self.configStepLine1 = self.productConfigStepLine.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + "config_step_id": self.config_step_engine.id, + "attribute_line_ids": [ + (6, 0, [self.attributeLine1.id, self.attributeLine2.id]) + ], + } + ) + # create config_step_line 2 + self.configStepLine2 = self.productConfigStepLine.create( + { + "product_tmpl_id": self.product_tmpl_id.id, + "config_step_id": self.config_step_body.id, + "attribute_line_ids": [(6, 0, [self.attributeLine3.id])], + } + ) + self.product_tmpl_id.write( + { + "config_step_line_ids": [ + (6, 0, [self.configStepLine1.id, self.configStepLine2.id]) + ], + } + ) + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_fuel.id}": self.value_gasoline.id, + f"__attribute_{self.attr_engine.id}": self.value_218i.id, + } + ) + product_config_wizard.action_next_step() + product_config_wizard.write( + { + f"__attribute_{self.attr_color.id}": self.value_red.id, + } + ) + return product_config_wizard + + def test_01_action_previous_step(self): + product_config_wizard = self._check_wizard_nxt_step() + product_config_wizard.action_previous_step() + self.assertEqual( + product_config_wizard.state, + str(self.configStepLine1.id), + "Error: If state are not equal\ + Method: action_next_step()", + ) + product_config_wizard.action_next_step() + self.assertEqual( + product_config_wizard.state, + str(self.configStepLine2.id), + "Error: If state are not equal\ + Method: action_next_step()", + ) + wizard_action = product_config_wizard.action_next_step() + variant_id2 = wizard_action.get("res_id") + self.assertTrue( + variant_id2, + "Error: If varient not exists\ + Method: action_next_step()", + ) + + def test_02_action_reset(self): + product_config_wizard = self._check_wizard_nxt_step() + action_wizard = product_config_wizard.action_reset() + product_tmpl_id = action_wizard.get("context") + self.assertTrue( + product_tmpl_id.get("default_product_tmpl_id"), + "Error: If product_tmpl_id not exists\ + Method: action_reset()", + ) + + def test_03_compute_attr_lines(self): + product_config_wizard = self._check_wizard_nxt_step() + product_config_wizard._compute_attr_lines() + self.assertTrue( + product_config_wizard.attribute_line_ids, + "Error: If atttribute_line_ids not exists\ + Method: _compute_attr_lines()", + ) + + def test_04_get_state_selection(self): + product_config_wizard = self._check_wizard_nxt_step() + config_wiz = product_config_wizard.with_context( + wizard_id=product_config_wizard.id + ).get_state_selection() + self.assertTrue( + config_wiz[1:], + "Error: If not config step selection\ + Method: get_state_selection()", + ) + + def test_05_compute_cfg_image(self): + product_config_wizard = self._check_wizard_nxt_step() + product_config_wizard._compute_cfg_image() + self.assertFalse( + product_config_wizard.product_img, + "Error: If product_img exists\ + Method: _compute_cfg_image()", + ) + + def test_06_onchange_product_tmpl(self): + product_config_wizard = self._check_wizard_nxt_step() + product_config_wizard.write( + { + "product_tmpl_id": self.config_product.id, + } + ) + with self.assertRaises(UserError): + product_config_wizard.onchange_product_tmpl() + + def test_07_get_onchange_domains(self): + product_config_wizard = self._check_wizard_nxt_step() + conf = [ + "gasoline", + "228i", + "model_luxury_line", + "silver", + "rims_384", + "tapistry_black", + "steptronic", + "smoker_package", + "tow_hook", + ] + values = [ + "gasoline", + "228i", + "model_luxury_line", + "silver", + "rims_384", + "tapistry_black", + "steptronic", + "smoker_package", + "tow_hook", + ] + product_config_wizard.get_onchange_domains(values, conf) + + def test_08_onchange_state(self): + product_config_wizard = self._check_wizard_nxt_step() + product_config_wizard._onchange_state() + + def test_09_onchange_product_preset(self): + product_config_wizard = self._check_wizard_nxt_step() + product_config_wizard._onchange_product_preset() + + def test_10_open_step(self): + wizard = self.env["product.configurator"] + step_to_open = wizard.config_session_id.check_and_open_incomplete_step() + wizard.open_step(step_to_open) + + def test_11_onchange(self): + field_name = "" + values = {f"__attribute_{self.attr_fuel.id}": self.value_gasoline.id} + product_config_wizard = self._check_wizard_nxt_step() + field_prefix = product_config_wizard._prefixes.get("field_prefix") + field_name = f"{field_prefix}{field_name}" + specs = product_config_wizard._onchange_spec() + product_config_wizard.onchange(values, field_name, specs) + + product_config_wizard.attribute_line_ids.update( + { + "attribute_id": self.attr_fuel.id, + "custom": True, + } + ) + values2 = { + f"__attribute_{self.attr_fuel.id}": self.custom_vals.id, + f"__custom_{self.attr_fuel.id}": "Test1", + } + product_config_wizard.onchange(values2, field_name, specs) + + def test_12_fields_get(self): + product_config_wizard = self._check_wizard_nxt_step() + product_config_wizard.fields_get() + product_config_wizard.with_context( + wizard_id=product_config_wizard.id + ).fields_get() + + # custom value + self.attr_line_fuel.custom = True + self.attr_line_engine.custom = True + product_config_wizard_1 = self.ProductConfWizard.create( + { + "product_tmpl_id": self.config_product.id, + } + ) + product_config_wizard_1.action_next_step() + product_config_wizard_1.write( + { + f"__attribute_{self.attr_fuel.id}": self.value_gasoline.id, + f"__custom_{self.attr_fuel.id}": "Test1", + f"__attribute_{self.attr_engine.id}": self.value_218i.id, + f"__custom_{self.attr_engine.id}": "Test2", + } + ) + product_config_wizard_1.action_next_step() + product_config_wizard_1.write( + { + f"__attribute_{self.attr_color.id}": self.value_red.id, + f"__attribute_{self.attr_rims.id}": self.value_rims_378.id, + } + ) + product_config_wizard_1.action_next_step() + product_config_wizard_1.write( + { + f"__attribute_{self.attr_model_line.id}": self.value_sport_line.id, + } + ) + product_config_wizard_1.action_previous_step() + product_config_wizard_1.action_previous_step() + product_config_wizard_1.write( + { + f"__attribute_{self.attr_engine.id}": self.value_220i.id, + } + ) + product_config_wizard_1.action_next_step() + product_config_wizard_1.action_next_step() + product_config_wizard_1.write( + { + f"__attribute_{self.attr_model_line.id}": self.value_model_sport_line.id, + } + ) + product_config_wizard_1.action_next_step() + product_config_wizard_1.write( + { + f"__attribute_{self.attr_tapistry.id}": self.value_tapistry.id, + } + ) + product_config_wizard_1.action_next_step() + product_config_wizard_1.write( + { + f"__attribute_{self.attr_transmission.id}": self.value_transmission.id, + f"__attribute_{self.attr_options.id}": [ + [6, 0, [self.value_options_2.id]] + ], + } + ) + product_config_wizard_1.action_next_step() + product_config_wizard_1.with_context( + wizard_id=product_config_wizard_1.id + ).fields_get() + + def test_13_fields_view_get(self): + product_config_wizard = self._check_wizard_nxt_step() + product_config_wizard.fields_view_get() + product_config_wizard.with_context( + wizard_id=product_config_wizard.id + ).fields_view_get() + # custom value + # custom value + self.attr_line_fuel.custom = True + self.attr_line_engine.custom = True + product_config_wizard_1 = self.ProductConfWizard.create( + { + "product_tmpl_id": self.config_product.id, + } + ) + product_config_wizard_1.action_next_step() + product_config_wizard_1.write( + { + f"__attribute_{self.attr_fuel.id}": self.value_gasoline.id, + f"__custom_{self.attr_fuel.id}": "Test1", + f"__attribute_{self.attr_engine.id}": self.value_218i.id, + f"__custom_{self.attr_engine.id}": "Test2", + } + ) + product_config_wizard_1.action_next_step() + product_config_wizard_1.write( + { + f"__attribute_{self.attr_color.id}": self.value_red.id, + f"__attribute_{self.attr_rims.id}": self.value_rims_378.id, + } + ) + product_config_wizard_1.action_next_step() + product_config_wizard_1.write( + { + f"__attribute_{self.attr_model_line.id}": self.value_sport_line.id, + } + ) + product_config_wizard_1.action_previous_step() + product_config_wizard_1.action_previous_step() + product_config_wizard_1.write( + { + f"__attribute_{self.attr_engine.id}": self.value_220i.id, + } + ) + product_config_wizard_1.action_next_step() + product_config_wizard_1.action_next_step() + product_config_wizard_1.write( + { + f"__attribute_{self.attr_model_line.id}": self.value_model_sport_line.id, + } + ) + product_config_wizard_1.action_next_step() + product_config_wizard_1.write( + { + f"__attribute_{self.attr_tapistry.id}": self.value_tapistry.id, + } + ) + product_config_wizard_1.action_next_step() + product_config_wizard_1.write( + { + f"__attribute_{self.attr_transmission.id}": self.value_transmission.id, + f"__attribute_{self.attr_options.id}": [ + [6, 0, [self.value_options_2.id]] + ], + } + ) + product_config_wizard_1.action_next_step() + product_config_wizard_1.with_context( + wizard_id=product_config_wizard_1.id + ).fields_view_get() + + def test_14_unlink(self): + product_config_wizard = self._check_wizard_nxt_step() + unlinkWizard = product_config_wizard.unlink() + self.assertTrue( + unlinkWizard, + "Error: If not unlink record\ + Method: unlink()", + ) + + def test_15_read(self): + product_config_wizard = self._check_wizard_nxt_step() + values = { + f"__attribute_{self.attr_fuel.id}": self.value_gasoline.id, + f"__attribute_{self.attr_engine.id}": self.value_218i.id, + f"__attribute_{self.attr_color.id}": self.value_red.id, + } + product_config_wizard.read(values) + product_tmpl = self.env["product.template"].create( + { + "name": "Test Custom", + "config_ok": True, + "type": "consu", + "categ_id": self.product_category.id, + } + ) + self.ProductConfWizard.action_next_step() + product_config_wizard_1 = self.ProductConfWizard.create( + { + "product_tmpl_id": product_tmpl.id, + } + ) + # create attribute line 1 + self.attributeLine1 = self.productAttributeLine.create( + { + "product_tmpl_id": product_tmpl.id, + "attribute_id": self.attr_fuel.id, + "value_ids": [(6, 0, [self.value_gasoline.id, self.value_diesel.id])], + "required": True, + "custom": True, + } + ) + # create attribute line 2 + self.attributeLine2 = self.productAttributeLine.create( + { + "product_tmpl_id": product_tmpl.id, + "attribute_id": self.attr_engine.id, + "value_ids": [(6, 0, [self.value_218i.id, self.value_220i.id])], + "required": True, + "custom": True, + } + ) + # create attribute line 2 + self.attributeLine3 = self.productAttributeLine.create( + { + "product_tmpl_id": product_tmpl.id, + "attribute_id": self.attr_engine.id, + "value_ids": [(6, 0, [self.value_218d.id, self.value_220d.id])], + "required": True, + } + ) + # configure product creating config step + self.configStepLine1 = self.productConfigStepLine.create( + { + "product_tmpl_id": product_tmpl.id, + "config_step_id": self.config_step_engine.id, + "attribute_line_ids": [ + (6, 0, [self.attributeLine1.id, self.attributeLine2.id]) + ], + } + ) + # create config_step_line 2 + self.configStepLine2 = self.productConfigStepLine.create( + { + "product_tmpl_id": product_tmpl.id, + "config_step_id": self.config_step_body.id, + "attribute_line_ids": [(6, 0, [self.attributeLine3.id])], + } + ) + product_tmpl.write( + { + "config_step_line_ids": [ + (6, 0, [self.configStepLine1.id, self.configStepLine2.id]) + ], + } + ) + product_config_wizard_1.action_next_step() + product_config_wizard_1.write( + { + f"__attribute_{self.attr_fuel.id}": self.custom_vals.id, + f"__custom_{self.attr_fuel.id}": "#DEFSRE", + f"__attribute_{self.attr_engine.id}": self.custom_vals.id, + f"__custom_{self.attr_engine.id}": "#FERDFGR", + } + ) + product_config_wizard_1.action_next_step() + product_config_wizard_1.write( + { + f"__attribute_{self.attr_color.id}": self.value_red.id, + } + ) + # check for custom value + custom_vals = { + f"__attribute_{self.attr_fuel.id}": self.custom_vals.id, + f"__custom_{self.attr_fuel.id}": "#DEFSRE", + f"__attribute_{self.attr_engine.id}": self.custom_vals.id, + f"__custom_{self.attr_engine.id}": "#FERDFGR", + f"__attribute_{self.attr_color.id}": self.value_red.id, + } + product_config_wizard_1.read(custom_vals) + session = self.productConfigSession.search( + [("product_tmpl_id", "=", product_tmpl.id)] + ) + session.unlink() + self.attributeLine1.custom = False + self.attributeLine1.multi = True + self.ProductConfWizard.action_next_step() + product_config_wizard_2 = self.ProductConfWizard.create( + { + "product_tmpl_id": product_tmpl.id, + } + ) + product_config_wizard_2.action_next_step() + product_config_wizard_2.write( + { + f"__attribute_{self.attr_fuel.id}": [ + (6, 0, [self.value_diesel.id, self.value_gasoline.id]) + ], + f"__attribute_{self.attr_engine.id}": self.custom_vals.id, + f"__custom_{self.attr_engine.id}": "#FERDFGR", + } + ) + product_config_wizard_2.action_next_step() + product_config_wizard_2.write( + { + f"__attribute_{self.attr_color.id}": self.value_red.id, + } + ) + # check for multi value + multi_vals = { + f"__attribute_{self.attr_fuel.id}": [ + (6, 0, [self.value_diesel.id, self.value_gasoline.id]) + ], + f"__attribute_{self.attr_engine.id}": self.custom_vals.id, + f"__custom_{self.attr_engine.id}": "#FERDFGR", + f"__attribute_{self.attr_color.id}": self.value_red.id, + } + product_config_wizard_2.read(multi_vals) + + def test_16_get_onchange_domains(self): + self.wizard = self.env["product.configurator"] + # session id + session_id = self.productConfigSession.create( + { + "product_tmpl_id": self.config_product.id, + "value_ids": [ + ( + 6, + 0, + [ + self.value_gasoline.id, + self.value_transmission.id, + self.value_red.id, + ], + ) + ], + "user_id": self.env.user.id, + } + ) + field_prefix = self.wizard._prefixes.get("field_prefix") + check_available_val_id = { + field_prefix + + "%s" % (self.value_gasoline.attribute_id.id): self.value_gasoline.id, + field_prefix + "%s" % (self.value_218i.attribute_id.id): self.value_218i.id, + field_prefix + + "%s" % (self.value_sport_line.attribute_id.id): self.value_sport_line.id, + } + values_ids = self.value_diesel.ids + product_tmpl_id = self.config_product + domains_available = self.wizard.get_onchange_domains( + check_available_val_id, values_ids, product_tmpl_id, session_id + ) + rec = domains_available[ + field_prefix + str(self.value_sport_line.attribute_id.id) + ][-1][-1] + self.assertNotIn( + self.value_sport_line.id, + rec, + "Error: If value exists\ + Method: get_onchange_domains()", + ) diff --git a/product_configurator/views/product_attribute_view.xml b/product_configurator/views/product_attribute_view.xml new file mode 100644 index 0000000000..16fb343374 --- /dev/null +++ b/product_configurator/views/product_attribute_view.xml @@ -0,0 +1,215 @@ + + + + + + + product.config.product.attribute.tree + product.attribute + + + + + + + + + + product.attribute.form.view + product.attribute + 100 + + + + + + + + + + base.group_no_one + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Attributes + ir.actions.act_window + product.attribute + tree,form + {'flag_config_ok': True} + + + + + + + + product.template.attribute.value.view.tree.weight.extra + product.template.attribute.value + + tree + + + + + + + + + product.template.attribute.value.view.form.weight.extra + product.template.attribute.value + + form + + + + + + + + + + + product.attribute.value.tree + product.attribute.value + + + + + + + + + + + product.config.product.attribute.value.form.view + product.attribute.value + +
+ + +
+
+
+
+ + + + + + + + +
+
+
+
+ + + Attribute Values + ir.actions.act_window + product.attribute.value + tree,form + + + + + + + product.template.attribute.line.form + product.template.attribute.line + + + + {'flag_config_ok': context.get('default_config_ok', False)} + + + + +
diff --git a/product_configurator/views/product_config_view.xml b/product_configurator/views/product_config_view.xml new file mode 100644 index 0000000000..2aec6444af --- /dev/null +++ b/product_configurator/views/product_config_view.xml @@ -0,0 +1,195 @@ + + + + + + + product.configurator.config.step.form + product.config.step + +
+ + + + + + + +
+
+
+ + + product.configurator.config.step.tree + product.config.step + +
+ + + +
+
+
+ + + + + product.configurator.domain.form + product.config.domain + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + product.configurator.domain.form.template + product.config.domain + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + product.config.session.tree + product.config.session + + + + + + + + + + + + + + + + + + + product.config.session.form + product.config.session + +
+
+ +
+ + +

+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
diff --git a/product_configurator/views/product_view.xml b/product_configurator/views/product_view.xml new file mode 100644 index 0000000000..8a24545bb0 --- /dev/null +++ b/product_configurator/views/product_view.xml @@ -0,0 +1,377 @@ + + + + + product.template.common.form + product.template + 16 + + + + + + + + + + product.configurator.product.template.form + product.template + 16 + + + + + + +
+ +
+
+ + + + { + 'show_attribute': False, + 'attribute_line_ids': attribute_line_ids, + } + + + + {'default_config_ok': context.get('default_config_ok', False)} + + + + + + + + + + + + not custom + + + + {'flag_config_ok': context.get('default_config_ok', False)} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + config_ok + + +
+
+ + + product.configurator.product.template.search.view + product.template + + + + + + + + + + + + product.template.product.tree + product.template + + + + context.get('default_config_ok', 0) + + + + + + Product.template.product.kanban + product.template + + + + + + +
+ + + +
+
+
+
+ + + product.configurator.form.view.custom.vals + product.product + + + +