diff --git a/product_configurator_wizard/tests/__init__.py b/product_configurator_wizard/tests/__init__.py index e037c216..404c70da 100644 --- a/product_configurator_wizard/tests/__init__.py +++ b/product_configurator_wizard/tests/__init__.py @@ -1,3 +1,4 @@ # -*- coding: utf-8 -*- from . import test_wizard +from . import test_wizard_attrs diff --git a/product_configurator_wizard/tests/test_wizard.py b/product_configurator_wizard/tests/test_wizard.py index 3acd8643..d9ccdb56 100644 --- a/product_configurator_wizard/tests/test_wizard.py +++ b/product_configurator_wizard/tests/test_wizard.py @@ -23,11 +23,13 @@ def get_attr_values(self, attr_val_ext_ids=None): return attr_vals - def get_wizard_write_dict(self, wizard, attr_values): + def get_wizard_write_dict(self, wizard, attr_values, remove_values=None): """Turn a series of attribute.value objects to a dictionary meant for writing values to the product.configurator wizard""" write_dict = {} + if remove_values is None: + remove_values = [] multi_attr_ids = wizard.product_tmpl_id.attribute_line_ids.filtered( lambda x: x.multi).mapped('attribute_id').ids @@ -40,6 +42,10 @@ def get_wizard_write_dict(self, wizard, attr_values): continue write_dict.update({field_name: val.id}) + for val in remove_values: + field_name = wizard.field_prefix + str(val.attribute_id.id) + write_dict.update({field_name: False}) + return write_dict def wizard_write_proceed(self, wizard, attr_vals, value_ids=None): @@ -94,30 +100,55 @@ def test_wizard_configuration(self): self.assertTrue(len(config_variants) == 1, "Wizard did not create a configurable variant") + def do_reconfigure(self, order_line, attr_vals): + reconfig_action = order_line.reconfigure_product() + + wizard = self.env['product.configurator'].browse( + reconfig_action.get('res_id') + ) + + self.wizard_write_proceed(wizard, attr_vals) + + # if wizard only had one step, it would already have completed + if wizard.exists(): + # Cycle through steps until wizard ends + while wizard.action_next_step(): + pass + def test_reconfiguration(self): """Test reconfiguration functionality of the wizard""" self.test_wizard_configuration() + existing_lines = self.so.order_line + order_line = self.so.order_line.filtered( lambda l: l.product_id.config_ok ) - reconfig_action = order_line.reconfigure_product() + self.do_reconfigure(order_line, + self.get_attr_values(['diesel', '220d']) + ) - wizard = self.env['product.configurator'].browse( - reconfig_action.get('res_id') - ) + config_variants = self.env['product.product'].search([ + ('config_ok', '=', True) + ]) - attr_vals = self.get_attr_values(['diesel', '220d']) - self.wizard_write_proceed(wizard, attr_vals) + self.assertTrue(len(config_variants) == 2, + "Wizard reconfiguration did not create a new variant") + + created_line = self.so.order_line - existing_lines + self.assertTrue(len(created_line) == 0, + "Wizard created an order line on reconfiguration") - # Cycle through steps until wizard ends - while wizard.action_next_step(): - pass + # test that running through again with the same values does not + # create another variant + self.do_reconfigure(order_line, + self.get_attr_values(['diesel', '220d']) + ) config_variants = self.env['product.product'].search([ ('config_ok', '=', True) ]) self.assertTrue(len(config_variants) == 2, - "Wizard reconfiguration did not create a new variant") + "Wizard reconfiguration created a redundant variant") diff --git a/product_configurator_wizard/tests/test_wizard_attrs.py b/product_configurator_wizard/tests/test_wizard_attrs.py new file mode 100644 index 00000000..4790fdad --- /dev/null +++ b/product_configurator_wizard/tests/test_wizard_attrs.py @@ -0,0 +1,201 @@ +# -*- coding: utf-8 -*- + +from odoo.addons.product_configurator_wizard.tests.test_wizard \ + import ConfigurationRules + + +class ConfigurationAttributes(ConfigurationRules): + + def setUp(self): + """ + Product with 3 sizes: + Small or Medium allow Blue or Red, but colour is optional + Large does not allow colour selection + """ + super(ConfigurationAttributes, self).setUp() + + self.attr_size = self.env['product.attribute'].create( + {'name': 'Size'}) + self.attr_val_small = self.env['product.attribute.value'].create( + {'attribute_id': self.attr_size.id, + 'name': 'Small', + } + ) + self.attr_val_med = self.env['product.attribute.value'].create( + {'attribute_id': self.attr_size.id, + 'name': 'Medium', + } + ) + self.attr_val_large = self.env['product.attribute.value'].create( + {'attribute_id': self.attr_size.id, + 'name': 'Large (Green)', + } + ) + domain_small_med = self.env['product.config.domain'].create( + {'name': 'Small/Med', + 'domain_line_ids': [ + (0, 0, {'attribute_id': self.attr_size.id, + 'condition': 'in', + 'operator': 'and', + 'value_ids': + [(6, 0, + [self.attr_val_small.id, self.attr_val_med.id] + )] + }), + ], + } + ) + self.attr_colour = self.env['product.attribute'].create( + {'name': 'Colour'}) + self.attr_val_blue = self.env['product.attribute.value'].create( + {'attribute_id': self.attr_colour.id, + 'name': 'Blue', + } + ) + self.attr_val_red = self.env['product.attribute.value'].create( + {'attribute_id': self.attr_colour.id, + 'name': 'Red', + } + ) + self.product_temp = self.env['product.template'].create( + {'name': 'Config Product', + 'config_ok': True, + 'type': 'product', + 'categ_id': self.env['ir.model.data'].xmlid_to_res_id( + 'product.product_category_5' + ), + 'attribute_line_ids': [ + (0, 0, {'attribute_id': self.attr_size.id, + 'value_ids': [ + (6, 0, self.attr_size.value_ids.ids), + ], + 'required': True, + }), + (0, 0, {'attribute_id': self.attr_colour.id, + 'value_ids': [ + (6, 0, self.attr_colour.value_ids.ids), + ], + 'required': False, + }) + ], + } + ) + colour_line = self.product_temp.attribute_line_ids.filtered( + lambda a: a.attribute_id == self.attr_colour) + self.env['product.config.line'].create({ + 'product_tmpl_id': self.product_temp.id, + 'attribute_line_id': colour_line.id, + 'value_ids': [(6, 0, self.attr_colour.value_ids.ids)], + 'domain_id': domain_small_med.id, + }) + + def test_configurations_option_or_not_reqd(self): + # Start a new configuration wizard + wizard_obj = self.env['product.configurator'].with_context({ + 'active_model': 'sale.order', + 'active_id': self.so.id + # 'default_order_id': self.so.id + }) + + wizard = wizard_obj.create({'product_tmpl_id': self.product_temp.id}) + wizard.action_next_step() + + dynamic_fields = {} + for attribute_line in self.product_temp.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 + field_name_colour = '%s%s' % ( + wizard.field_prefix, + self.attr_colour.id + ) + invisible_name_colour = '%s%s' % ( + wizard.invisible_field_prefix, + self.attr_colour.id + ) + invisible_name_size = '%s%s' % ( + wizard.invisible_field_prefix, + self.attr_size.id + ) + + # Define small without colour specified + self.wizard_write_proceed(wizard, [self.attr_val_small]) + new_variant = self.product_temp.product_variant_ids + self.assertTrue(len(new_variant) == 1 and + set(new_variant.attribute_value_ids.ids) == + set([self.attr_val_small.id]), + "Wizard did not accurately create a variant with " + "optional value undefined") + config_variants = self.product_temp.product_variant_ids + + order_line = self.so.order_line.filtered( + lambda l: l.product_id.config_ok + ) + + # Redefine to medium without colour + self.do_reconfigure(order_line, [self.attr_val_med]) + new_variant = self.product_temp.product_variant_ids - config_variants + self.assertTrue(len(new_variant) == 1 and + set(new_variant.attribute_value_ids.ids) == + set([self.attr_val_med.id]), + "Wizard did not accurately reconfigure a variant with " + "optional value undefined") + config_variants = self.product_temp.product_variant_ids + + # Redefine to medium blue + self.do_reconfigure(order_line, [self.attr_val_blue]) + new_variant = self.product_temp.product_variant_ids - config_variants + self.assertTrue(len(new_variant) == 1 and + set(new_variant.attribute_value_ids.ids) == + set([self.attr_val_med.id, self.attr_val_blue.id]), + "Wizard did not accurately reconfigure a variant with " + "to add an optional value") + config_variants = self.product_temp.product_variant_ids + + # Redefine to large - should remove colour, as this is invalid + reconfig_action = order_line.reconfigure_product() + wizard = self.env['product.configurator'].browse( + reconfig_action.get('res_id') + ) + attr_large_dict = self.get_wizard_write_dict(wizard, + [self.attr_val_large]) + attr_blue_dict = self.get_wizard_write_dict(wizard, + [self.attr_val_blue]) + oc_vals = dynamic_fields.copy() + oc_vals.update({'id': wizard.id}) + oc_vals.update(dict(attr_blue_dict, **attr_large_dict)) + oc_result = wizard.onchange( + oc_vals, + attr_large_dict.keys()[0], + {} + ) + self.assertTrue(field_name_colour in oc_result['value'] and + not oc_result['value'][field_name_colour], + "Colour should have been cleared by wizard" + ) + self.assertTrue(invisible_name_colour in oc_result['value'] and + not oc_result['value'][invisible_name_colour], + "Invisible Attribute Colour should have been " + "cleared by wizard" + ) + self.assertTrue(invisible_name_size in oc_result['value'] and + oc_result['value'][invisible_name_size] == + self.attr_val_large.id, + "Invisible Attribute Size should have been set " + "by wizard" + ) + vals = self.get_wizard_write_dict(wizard, [self.attr_val_large], + remove_values=[self.attr_val_blue]) + wizard.write(vals) + wizard.action_next_step() + if wizard.exists(): + while wizard.action_next_step(): + pass + new_variant = self.product_temp.product_variant_ids - config_variants + self.assertTrue(len(new_variant) == 1 and + set(new_variant.attribute_value_ids.ids) == + set([self.attr_val_large.id]), + "Wizard did not accurately reconfigure a variant with " + "to remove invalid attribute") diff --git a/product_configurator_wizard/wizard/product_configurator.py b/product_configurator_wizard/wizard/product_configurator.py index 5e0055d0..80d57e20 100644 --- a/product_configurator_wizard/wizard/product_configurator.py +++ b/product_configurator_wizard/wizard/product_configurator.py @@ -21,6 +21,7 @@ class ProductConfigurator(models.TransientModel): # Prefix for the dynamicly injected fields field_prefix = '__attribute-' custom_field_prefix = '__custom-' + invisible_field_prefix = '__invisible-' # TODO: Since the configuration process can take a bit of time # depending on complexity and AFK time we must increase the lifespan @@ -78,9 +79,9 @@ def onchange_product_tmpl(self): def get_onchange_domains(self, values, cfg_val_ids): """Generate domains to be returned by onchange method in order - to restrict the availble values of dynamically inserted fields + to restrict the available values of dynamically inserted fields - :param values: values argument passed to onchance wrapper + :param values: values argument passed to onchange wrapper :cfg_val_ids: current configuration passed as a list of value_ids (usually in the form of db value_ids + interface value_ids) @@ -197,6 +198,20 @@ def onchange(self, values, field_name, field_onchange): domains = self.get_onchange_domains(values, cfg_val_ids) vals = self.get_form_vals(dynamic_fields, domains) + + # the on changed value is stored in "invisible" equivalent in case they + # become readonly + for k, v in values.items(): + if k.startswith(self.field_prefix): + vals[k.replace(self.field_prefix, + self.invisible_field_prefix, 1) + ] = v + # likewise, any returned values will be co-stored + for k, v in vals.items(): + if k.startswith(self.field_prefix): + vals[k.replace(self.field_prefix, + self.invisible_field_prefix, 1) + ] = v return {'value': vals, 'domain': domains} attribute_line_ids = fields.One2many( @@ -335,6 +350,13 @@ def fields_get(self, allfields=None, attributes=None): relation='product.attribute.value', sequence=line.sequence, ) + res[self.invisible_field_prefix + str(attribute.id)] = dict( + default_attrs, + type='many2many' if line.multi else 'many2one', + string=line.attribute_id.name, + relation='product.attribute.value', + sequence=line.sequence, + ) return res @@ -358,8 +380,10 @@ def fields_view_get(self, view_id=None, view_type='form', # Get updated fields including the dynamic ones fields = self.fields_get() dynamic_fields = { - k: v for k, v in fields.iteritems() if k.startswith( - self.field_prefix) or k.startswith(self.custom_field_prefix) + k: v for k, v in fields.iteritems() if + k.startswith(self.field_prefix) or + k.startswith(self.custom_field_prefix) or + k.startswith(self.invisible_field_prefix) } res['fields'].update(dynamic_fields) @@ -367,7 +391,6 @@ 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)}) - return res @api.model @@ -405,6 +428,7 @@ def add_dynamic_fields(self, res, dynamic_fields, wiz): attribute_id = attr_line.attribute_id.id field_name = self.field_prefix + str(attribute_id) custom_field = self.custom_field_prefix + str(attribute_id) + invisible_field = self.invisible_field_prefix + str(attribute_id) # Check if the attribute line has been added to the db fields if field_name not in dynamic_fields: @@ -498,6 +522,17 @@ def add_dynamic_fields(self, res, dynamic_fields, wiz): orm.setup_modifiers(node) xml_dynamic_form.append(node) + # Create the new invisible field in the view + node = etree.Element( + "field", + name=invisible_field, + invisible='1' + ) + if field_type == 'many2many': + node.attrib['widget'] = 'many2many_tags' + orm.setup_modifiers(node) + xml_dynamic_form.append(node) + if attr_line.custom and custom_field in dynamic_fields: widget = '' custom_ext_id = 'product_configurator.custom_attribute_value' @@ -560,8 +595,11 @@ def read(self, fields=None, load='_classic_read'): custom_attr_vals = [ f for f in fields if f.startswith(self.custom_field_prefix) ] + invis_vals = [ + f for f in fields if f.startswith(self.invisible_field_prefix) + ] - dynamic_fields = attr_vals + custom_attr_vals + dynamic_fields = attr_vals + custom_attr_vals + invis_vals fields = [f for f in fields if f not in dynamic_fields] custom_ext_id = 'product_configurator.custom_attribute_value' @@ -580,6 +618,8 @@ def read(self, fields=None, load='_classic_read'): if field_name not in dynamic_fields: continue + invis_field_name = self.invisible_field_prefix + str(attr_id) + custom_field_name = self.custom_field_prefix + str(attr_id) custom_vals = self.custom_value_ids.filtered( lambda x: x.attribute_id.id == attr_id) @@ -592,6 +632,7 @@ def read(self, fields=None, load='_classic_read'): if attr_line.custom and custom_vals: dynamic_vals.update({ field_name: custom_val.id, + invis_field_name: custom_val.id, }) if attr_line.attribute_id.custom_type == 'binary': dynamic_vals.update({ @@ -602,11 +643,15 @@ def read(self, fields=None, load='_classic_read'): custom_field_name: custom_vals.eval() }) elif attr_line.multi: - dynamic_vals = {field_name: [[6, 0, vals.ids]]} + dynamic_vals = {field_name: [[6, 0, vals.ids]], + invis_field_name: [[6, 0, vals.ids]] + } else: try: vals.ensure_one() - dynamic_vals = {field_name: vals.id} + dynamic_vals = {field_name: vals.id, + invis_field_name: vals.id + } except: continue res[0].update(dynamic_vals) @@ -625,28 +670,45 @@ def write(self, vals): attr_val_dict = {} custom_val_dict = {} + # attributes which are part of the current step which are + # readonly are not submitted by the client! We have to make sure + # these are treated as values to be written. + # They are submitted as part of an "invisible" field which is + # not readonly so that we can use that instead. + for attr_line in self.product_tmpl_id.attribute_line_ids: attr_id = attr_line.attribute_id.id field_name = self.field_prefix + str(attr_id) custom_field_name = self.custom_field_prefix + str(attr_id) + invisible_field_name = self.invisible_field_prefix + str(attr_id) - if field_name not in vals and custom_field_name not in vals: + if field_name not in vals and \ + custom_field_name not in vals and \ + invisible_field_name not in vals: continue + # If field value is not submitted, then perhaps it was readonly, + # in which case we should look at the invisible equivalent. + # If it was not submitted by the client, then it implies that we + # are here because a custom attribute was submitted. + field_val = vals[field_name] if field_name in vals else \ + vals[invisible_field_name] if invisible_field_name in vals \ + else custom_val.id + # 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]: + if field_val != custom_val.id: + if attr_line.multi and isinstance(field_val, list): + if not field_val: field_val = None else: - field_val = vals[field_name][0][2] - elif not attr_line.multi and isinstance(vals[field_name], int): - field_val = vals[field_name] + field_val = field_val[0][2] + elif not attr_line.multi and isinstance(field_val, int): + pass else: raise Warning( - _('An error occursed while parsing value for ' + _('An error occurred while parsing value for ' 'attribute %s' % attr_line.attribute_id.name) ) attr_val_dict.update({