diff --git a/product_configurator/__manifest__.py b/product_configurator/__manifest__.py index 4f75285a..dd241883 100755 --- a/product_configurator/__manifest__.py +++ b/product_configurator/__manifest__.py @@ -24,6 +24,7 @@ 'demo/product_attribute.xml', 'demo/product_config_domain.xml', 'demo/product_config_lines.xml', + 'demo/product_config_defaults.xml', 'demo/product_config_step.xml', 'demo/config_image_ids.xml', ], diff --git a/product_configurator/demo/product_config_defaults.xml b/product_configurator/demo/product_config_defaults.xml new file mode 100644 index 00000000..a305ccc7 --- /dev/null +++ b/product_configurator/demo/product_config_defaults.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + diff --git a/product_configurator/models/product.py b/product_configurator/models/product.py index 9a6bfe56..62762c12 100644 --- a/product_configurator/models/product.py +++ b/product_configurator/models/product.py @@ -17,6 +17,12 @@ class ProductTemplate(models.Model): string="Attribute Dependencies" ) + config_default_ids = fields.One2many( + comodel_name='product.config.default', + inverse_name='product_tmpl_id', + string="Attribute Defaults" + ) + config_image_ids = fields.One2many( comodel_name='product.config.image', inverse_name='product_tmpl_id', @@ -439,6 +445,44 @@ def values_available(self, attr_val_ids, sel_val_ids): return avail_val_ids + @api.multi + def find_default_value(self, selectable_value_ids, value_ids): + """Based on the current values, which of the available template value ids + is the best default value to use. + + :param selectable_value_ids: list of product.attribute.value + object already trimmed down as selectable, for one + attribute line. + :param value_ids: list of attribute value ids already chosen + + :returns: The first matched default id + + """ + self.ensure_one() + + if not selectable_value_ids: + return False + # assume all values are from the same attribute line - they should be! + default_lines = self.config_default_ids.filtered( + lambda l: set(l.value_ids.ids) & set(selectable_value_ids) + ) + + for default_line in default_lines: + if not default_line.domain_id: + # No domain - always considered true. Use this. + break + domains = default_line.mapped('domain_id').compute_domain() + if self.validate_domains_against_sels(domains, value_ids): + # Domain OK, use this + break + else: + # parsed all lines without a match + return False + # pick one at random... + return ( + set(default_line.value_ids.ids) & set(selectable_value_ids) + ).pop() + @api.multi def validate_configuration(self, value_ids, custom_vals=None, final=True): """ Verifies if the configuration values passed via value_ids and custom_vals diff --git a/product_configurator/models/product_config.py b/product_configurator/models/product_config.py index 64ae1a31..bf723bed 100644 --- a/product_configurator/models/product_config.py +++ b/product_configurator/models/product_config.py @@ -197,6 +197,59 @@ def check_value_attributes(self): ) +class ProductConfigDefault(models.Model): + _name = 'product.config.default' + + product_tmpl_id = fields.Many2one( + comodel_name='product.template', + string='Product Template', + 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', + compute='_compute_attr_vals' + ) + + value_ids = fields.Many2many( + comodel_name='product.attribute.value', + id1="cfg_dflt_id", + id2="attr_val_id", + string="Values" + ) + + domain_id = fields.Many2one( + comodel_name='product.config.domain', + string='Applied If', + help='Default will be attempted if this rule passes, or leave blank ' + 'for a generic default' + ) + + sequence = fields.Integer(string='Sequence', default=10) + + _order = 'product_tmpl_id, sequence, id' + + @api.multi + def _compute_attr_vals(self): + for config_default in self: + config_default.attr_line_val_ids = \ + config_default.product_tmpl_id.attribute_line_ids.mapped( + 'value_ids' + ) + + @api.model + def default_get(self, fields): + result = super(ProductConfigDefault, self).default_get(fields) + if self.env.context.get('for_template_id'): + result['attr_line_val_ids'] = \ + self.env['product.template'].browse( + self.env.context['for_template_id'] + ).attribute_line_ids.mapped('value_ids').ids + return result + + class ProductConfigImage(models.Model): _name = 'product.config.image' diff --git a/product_configurator/security/ir.model.access.csv b/product_configurator/security/ir.model.access.csv index 5d1d44e2..e21b3421 100644 --- a/product_configurator/security/ir.model.access.csv +++ b/product_configurator/security/ir.model.access.csv @@ -1,5 +1,6 @@ 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,1,1,1 +product_configurator_config_default,Config Default,model_product_config_default,group_product_configurator,1,1,1,1 product_configurator_config_image,Config Image,model_product_config_image,group_product_configurator,1,1,1,1 product_configurator_config_step,Config Step,model_product_config_step,group_product_configurator,1,1,1,1 product_configurator_config_step_line,Config Step Line,model_product_config_step_line,group_product_configurator,1,1,1,1 @@ -10,6 +11,7 @@ product_configurator_config_session,Config Session,model_product_config_session, 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_default,User Config Default,model_product_config_default,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 @@ -25,6 +27,7 @@ portal_config_step,Portal Config Step,model_product_config_step,base.group_porta 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_default,Portal Config Default,model_product_config_default,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 diff --git a/product_configurator/tests/test_configuration_rules.py b/product_configurator/tests/test_configuration_rules.py index 984a3010..feac814e 100644 --- a/product_configurator/tests/test_configuration_rules.py +++ b/product_configurator/tests/test_configuration_rules.py @@ -101,4 +101,41 @@ def test_invalid_custom_value_configuration(self): self.assertFalse(validation, "Custom value accepted for fixed " "attribute color") - # TODO: Test configuration with disallowed custom type value + def test_configuration_defaults(self): + conf = ['gasoline', 'tapistry_black'] + engine_selections = self.env.ref( + 'product_configurator.product_config_line_gasoline_engines' + ) + attr_val_ids = self.get_attr_val_ids(conf) + default_value_engine = self.cfg_tmpl.find_default_value( + engine_selections.value_ids.ids, + attr_val_ids, + ) + self.assertEqual( + [default_value_engine], self.get_attr_val_ids(['218i']), + "Gasoline Engine default not set correctly" + ) + + color_selection_ids = self.get_attr_val_ids(['red', 'silver', 'black']) + attr_val_ids = self.get_attr_val_ids(conf) + default_value_color = self.cfg_tmpl.find_default_value( + color_selection_ids, + attr_val_ids, + ) + self.assertEqual( + [default_value_color], self.get_attr_val_ids(['red']), + "Gasoline Color default not set correctly" + ) + + color_selection_ids = self.get_attr_val_ids(['silver', 'black']) + attr_val_ids = self.get_attr_val_ids(conf) + default_value_color = self.cfg_tmpl.find_default_value( + color_selection_ids, + attr_val_ids, + ) + self.assertFalse( + default_value_color, + "Gasoline Color should not have been returned unselectable value" + ) + + # Test configuration with disallowed custom type value diff --git a/product_configurator/views/product_view.xml b/product_configurator/views/product_view.xml index 1519dece..994f0ab9 100644 --- a/product_configurator/views/product_view.xml +++ b/product_configurator/views/product_view.xml @@ -65,6 +65,24 @@ + + + + + + + + + + diff --git a/product_configurator_wizard/tests/test_wizard.py b/product_configurator_wizard/tests/test_wizard.py index 3acd8643..2e410242 100644 --- a/product_configurator_wizard/tests/test_wizard.py +++ b/product_configurator_wizard/tests/test_wizard.py @@ -121,3 +121,51 @@ def test_reconfiguration(self): self.assertTrue(len(config_variants) == 2, "Wizard reconfiguration did not create a new variant") + + def test_wizard_domains(self): + """Test product configurator wizard default values""" + + # Start a new configuration wizard + wizard = self.env['product.configurator'].create({ + 'product_tmpl_id': self.cfg_tmpl.id + }) + + dynamic_fields = {} + for attribute_line in self.cfg_tmpl.attribute_line_ids: + field_name = '%s%s' % ( + wizard.field_prefix, + attribute_line.attribute_id.id + ) + dynamic_fields[field_name] = [] if attribute_line.multi else False + + attr_gasoline_vals = self.get_attr_values(['gasoline']) + attr_gasoline_dict = self.get_wizard_write_dict(wizard, + attr_gasoline_vals) + attr_218i_vals = self.get_attr_values(['218i']) + attr_218i_dict = self.get_wizard_write_dict(wizard, attr_218i_vals) + gasoline_engine_vals = self.env.ref( + 'product_configurator.product_config_line_gasoline_engines' + ).value_ids + + oc_vals = dynamic_fields.copy() + oc_vals.update({'id': wizard.id, + }) + oc_vals.update(attr_gasoline_dict) + oc_result = wizard.onchange( + oc_vals, + attr_gasoline_dict.keys()[0], + {} + ) + k, v = attr_218i_dict.iteritems().next() + self.assertEqual( + oc_result.get('value', {}).get(k), + v, + "Engine default value not set correctly by onchange wizard" + ) + oc_domain = oc_result.get('domain', {}).get(k, []) + domain_ids = oc_domain and oc_domain[0][2] or [] + self.assertEqual( + set(domain_ids), + set(gasoline_engine_vals.ids), + "Engine domain value not set correctly by onchange wizard" + ) diff --git a/product_configurator_wizard/wizard/product_configurator.py b/product_configurator_wizard/wizard/product_configurator.py index 5e0055d0..674cc8ee 100644 --- a/product_configurator_wizard/wizard/product_configurator.py +++ b/product_configurator_wizard/wizard/product_configurator.py @@ -109,10 +109,12 @@ def get_onchange_domains(self, values, cfg_val_ids): continue return domains - def get_form_vals(self, dynamic_fields, domains): + def get_form_vals(self, dynamic_fields, domains, cfg_step): """Generate a dictionary to return new values via onchange method. Domains hold the values available, this method enforces these values if a selection exists in the view that is not available anymore. + Also, if there are values blanked out by this, then try and see if + there is an available default. :param dynamic_fields: Dictionary with the current {dynamic_field: val} :param domains: Odoo domains restricting attribute values @@ -121,22 +123,56 @@ def get_form_vals(self, dynamic_fields, domains): """ vals = {} - dynamic_fields = {k: v for k, v in dynamic_fields.iteritems() if v} + dynamic_fields = dynamic_fields.copy() + # validate and eliminate values, and set defaults if they are on the + # current step + step_val_ids = cfg_step and \ + cfg_step.attribute_line_ids.mapped('value_ids').ids or \ + self.product_tmpl_id.attribute_line_ids.mapped('value_ids').ids for k, v in dynamic_fields.iteritems(): + available_val_ids = domains[k][0][2] + # Get this fresh every time as the loop can change the values as + # it goes! + config_val_ids = [dfv for dfv in dynamic_fields.values() + if dfv and not isinstance(dfv, list)] + for list_dfv in [dfv for dfv in dynamic_fields.values() + if dfv and isinstance(dfv, list)]: + config_val_ids.extend(list_dfv[0][2]) if not v: + # if the value currently is blank and on the current step, see + # if one can be set + if set(available_val_ids) & set(step_val_ids): + def_value_id = self.product_tmpl_id.find_default_value( + available_val_ids, config_val_ids + ) + if def_value_id: + dynamic_fields.update({k: def_value_id}) + vals[k] = def_value_id continue - available_val_ids = domains[k][0][2] if isinstance(v, list): value_ids = list(set(v[0][2]) & set(available_val_ids)) - dynamic_fields.update({k: value_ids}) + dynamic_fields[k] = [[6, 0, value_ids]] vals[k] = [[6, 0, value_ids]] elif v not in available_val_ids: - dynamic_fields.update({k: None}) - vals[k] = None - + # if the value is to be blanked, and it is on the current + # step, see if a default can be set + if set(available_val_ids) & set(step_val_ids): + def_value_id = self.product_tmpl_id.find_default_value( + available_val_ids, config_val_ids + ) or None + else: + def_value_id = None + dynamic_fields.update({k: def_value_id}) + vals[k] = def_value_id + + config_val_ids = [dfv for dfv in dynamic_fields.values() + if dfv and not isinstance(dfv, list)] + for list_dfv in [dfv for dfv in dynamic_fields.values() + if dfv and isinstance(dfv, list)]: + config_val_ids.extend(list_dfv[0][2]) product_img = self.product_tmpl_id.get_config_image_obj( - dynamic_fields.values()) + config_val_ids) vals.update(product_img=product_img.image) @@ -196,7 +232,34 @@ def onchange(self, values, field_name, field_onchange): cfg_val_ids = cfg_vals.ids + list(view_val_ids) domains = self.get_onchange_domains(values, cfg_val_ids) - vals = self.get_form_vals(dynamic_fields, domains) + vals = self.get_form_vals(dynamic_fields, domains, cfg_step) + modified_dynamics = {k: v + for k, v in vals.iteritems() + if k in dynamic_fields} + + while modified_dynamics: + # modified values may change domains! + dynamic_fields.update(modified_dynamics) + for k, v in modified_dynamics.iteritems(): + attr_id = int(k.split(self.field_prefix)[1]) + view_val_ids -= set(self.env['product.attribute.value'].search( + [('attribute_id', '=', attr_id)]).ids) + if v: + if isinstance(v, list): + view_val_ids |= set(v[0][2]) + elif isinstance(v, int): + view_val_ids.add(v) + + cfg_val_ids = cfg_vals.ids + list(view_val_ids) + + domains = self.get_onchange_domains(values, cfg_val_ids) + nvals = self.get_form_vals(dynamic_fields, domains, cfg_step) + # Stop possible recursion by not including values which have + # previously looped + modified_dynamics = {k: v + for k, v in nvals.iteritems() + if k in dynamic_fields and k not in vals} + vals.update(nvals) return {'value': vals, 'domain': domains} attribute_line_ids = fields.One2many( @@ -273,6 +336,8 @@ def fields_get(self, allfields=None, attributes=None): # If no configuration steps exist then get all attribute lines attribute_lines = wiz.product_tmpl_id.attribute_line_ids + # TODO: If last block is attempting to be clever, this next + # line is ignoring it. Need to determine what is best. attribute_lines = wiz.product_tmpl_id.attribute_line_ids # Generate relational fields with domains restricting values to @@ -368,6 +433,26 @@ def fields_view_get(self, view_id=None, view_type='form', # Update result dict from super with modified view res.update({'arch': etree.tostring(mod_view)}) + # set any default values + wiz_vals = wiz.read(dynamic_fields.keys())[0] + dynamic_field_vals = { + k: wiz_vals.get( + k, [] if v['type'] == 'many2many' else False + ) + for k, v in fields.iteritems() + if k.startswith(self.field_prefix) + } + domains = {k: dynamic_fields[k]['domain'] + for k in dynamic_field_vals.keys()} + try: + cfg_step_id = int(wiz.state) + cfg_step = wiz.product_tmpl_id.config_step_line_ids.filtered( + lambda x: x.id == cfg_step_id) + except: + cfg_step = self.env['product.config.step.line'] + vals = wiz.get_form_vals(dynamic_field_vals, domains, cfg_step) + if vals: + wiz.write(vals) return res @api.model