diff --git a/addons/product/__manifest__.py b/addons/product/__manifest__.py
index 228d69b2b7a..8bb0d8efdc7 100644
--- a/addons/product/__manifest__.py
+++ b/addons/product/__manifest__.py
@@ -37,6 +37,7 @@ Print product labels with barcode.
'views/res_config_settings_views.xml',
'views/product_attribute_views.xml',
+ 'views/product_attribute_value_views.xml',
'views/product_category_views.xml',
'views/product_document_views.xml',
'views/product_packaging_views.xml',
diff --git a/addons/product/models/product_attribute.py b/addons/product/models/product_attribute.py
index c0d32f94469..b3319afa471 100644
--- a/addons/product/models/product_attribute.py
+++ b/addons/product/models/product_attribute.py
@@ -11,6 +11,14 @@ class ProductAttribute(models.Model):
# `_sort_key_attribute_value` in `product.template`
_order = 'sequence, id'
+ _sql_constraints = [
+ (
+ 'check_multi_checkbox_no_variant',
+ "CHECK(display_type != 'multi' OR create_variant = 'no_variant')",
+ "Multi-checkbox display type is not compatible with the creation of variants"
+ ),
+ ]
+
name = fields.Char(string="Attribute", required=True, translate=True)
create_variant = fields.Selection(
selection=[
@@ -31,6 +39,7 @@ class ProductAttribute(models.Model):
('pills', 'Pills'),
('select', 'Select'),
('color', 'Color'),
+ ('multi', 'Multi-checkbox (option)'),
],
default='radio',
required=True,
diff --git a/addons/product/models/product_attribute_value.py b/addons/product/models/product_attribute_value.py
index 7c4033ea847..b6804793095 100644
--- a/addons/product/models/product_attribute_value.py
+++ b/addons/product/models/product_attribute_value.py
@@ -34,6 +34,7 @@ class ProductAttributeValue(models.Model):
is_used_on_products = fields.Boolean(
string="Used on Products", compute='_compute_is_used_on_products')
+ default_extra_price = fields.Float()
is_custom = fields.Boolean(
string="Is custom value",
help="Allow users to input custom values for this attribute value")
diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py
index 63104d33152..2d6ef6d684e 100644
--- a/addons/product/models/product_template.py
+++ b/addons/product/models/product_template.py
@@ -1257,7 +1257,9 @@ class ProductTemplate(models.Model):
necessary_values = necessary_values or self.env['product.template.attribute.value']
necessary_attribute_lines = necessary_values.mapped('attribute_line_id')
- attribute_lines = self.valid_product_template_attribute_line_ids.filtered(lambda ptal: ptal not in necessary_attribute_lines)
+ attribute_lines = self.valid_product_template_attribute_line_ids.filtered(
+ lambda ptal: ptal not in necessary_attribute_lines and (
+ ptal.attribute_id.display_type != 'multi'))
if not attribute_lines and self._is_combination_possible(necessary_values, parent_combination):
yield necessary_values
diff --git a/addons/product/models/product_template_attribute_line.py b/addons/product/models/product_template_attribute_line.py
index adfc88170ad..9cc23fb1ad1 100644
--- a/addons/product/models/product_template_attribute_line.py
+++ b/addons/product/models/product_template_attribute_line.py
@@ -235,7 +235,8 @@ class ProductTemplateAttributeLine(models.Model):
# create values that didn't exist yet
ptav_to_create.append({
'product_attribute_value_id': pav.id,
- 'attribute_line_id': ptal.id
+ 'attribute_line_id': ptal.id,
+ 'price_extra': pav.default_extra_price,
})
# Handle active at each step in case a following line might want to
# re-use a value that was archived at a previous step.
diff --git a/addons/product/views/product_attribute_value_views.xml b/addons/product/views/product_attribute_value_views.xml
new file mode 100644
index 00000000000..1254bff6fe4
--- /dev/null
+++ b/addons/product/views/product_attribute_value_views.xml
@@ -0,0 +1,15 @@
+
+
+
+
+ product.attribute.value.list
+ product.attribute.value
+
+
+
+
+
+
+
+
+
diff --git a/addons/product/views/product_attribute_views.xml b/addons/product/views/product_attribute_views.xml
index 6410cde4351..11703518fdf 100644
--- a/addons/product/views/product_attribute_views.xml
+++ b/addons/product/views/product_attribute_views.xml
@@ -30,11 +30,13 @@
-
-
-
-
-
+
+
+
+
+
+
+
@@ -43,8 +45,9 @@
-
+
+
diff --git a/addons/sale/models/sale_order_line.py b/addons/sale/models/sale_order_line.py
index 416a036a85f..3a4c10b5177 100644
--- a/addons/sale/models/sale_order_line.py
+++ b/addons/sale/models/sale_order_line.py
@@ -9,7 +9,7 @@ from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.fields import Command
from odoo.osv import expression
-from odoo.tools import float_is_zero, float_compare, float_round, format_date
+from odoo.tools import float_is_zero, float_compare, float_round, format_date, groupby
class SaleOrderLine(models.Model):
@@ -365,12 +365,21 @@ class SaleOrderLine(models.Model):
custom_ptavs = self.product_custom_attribute_value_ids.custom_product_template_attribute_value_id
no_variant_ptavs = self.product_no_variant_attribute_value_ids._origin
+ multi_ptavs = no_variant_ptavs.filtered(lambda ptav: ptav.display_type == 'multi').sorted()
# display the no_variant attributes, except those that are also
# displayed by a custom (avoid duplicate description)
- for ptav in (no_variant_ptavs - custom_ptavs):
+ for ptav in (no_variant_ptavs - multi_ptavs - custom_ptavs):
name += "\n" + ptav.display_name
+ # display the selected values per attribute on a single for a multi checkbox
+ for pta, ptavs in groupby(multi_ptavs, lambda ptav: ptav.attribute_id):
+ name += "\n" + _(
+ "%(attribute)s: %(values)s",
+ attribute=pta.name,
+ values=", ".join(ptav.name for ptav in ptavs)
+ )
+
# Sort the values according to _order settings, because it doesn't work for virtual records in onchange
sorted_custom_ptav = self.product_custom_attribute_value_ids.custom_product_template_attribute_value_id.sorted()
for patv in sorted_custom_ptav:
diff --git a/addons/sale_product_configurator/controllers/main.py b/addons/sale_product_configurator/controllers/main.py
index f5d4d8ad175..cca3513900f 100644
--- a/addons/sale_product_configurator/controllers/main.py
+++ b/addons/sale_product_configurator/controllers/main.py
@@ -280,9 +280,9 @@ class ProductConfiguratorController(Controller):
) for ptav in ptal.product_template_value_ids
if ptav.ptav_active
],
- selected_attribute_value_id=combination.filtered(
+ selected_attribute_value_ids=combination.filtered(
lambda c: ptal in c.attribute_line_id
- ).id,
+ ).ids,
create_variant=ptal.attribute_id.create_variant,
) for ptal in product_template.attribute_line_ids],
exclusions=attribute_exclusions['exclusions'],
diff --git a/addons/sale_product_configurator/static/src/js/product_configurator_dialog/product_configurator_dialog.js b/addons/sale_product_configurator/static/src/js/product_configurator_dialog/product_configurator_dialog.js
index 66c55cb7da9..87b1eeddf5f 100644
--- a/addons/sale_product_configurator/static/src/js/product_configurator_dialog/product_configurator_dialog.js
+++ b/addons/sale_product_configurator/static/src/js/product_configurator_dialog/product_configurator_dialog.js
@@ -206,17 +206,28 @@ export class ProductConfiguratorDialog extends Component {
}
/**
- * Change the value of `selected_attribute_value_id` on the given PTAL in the product.
+ * Change the value of `selected_attribute_value_ids` on the given PTAL in the product.
*
* @param {Number} productTmplId - The product template id, as a `product.template` id.
* @param {Number} ptalId - The PTAL id, as a `product.template.attribute.line` id.
* @param {Number} ptavId - The PTAV id, as a `product.template.attribute.value` id.
+ * @param {Boolean} multiIdsAllowed - Whether multiple `product.template.attribute.value` can be selected.
*/
- async _updateProductTemplateSelectedPTAV(productTmplId, ptalId, ptavId) {
+ async _updateProductTemplateSelectedPTAV(productTmplId, ptalId, ptavId, multiIdsAllowed) {
const product = this._findProduct(productTmplId);
- product.attribute_lines.find(
- ptal => ptal.id === ptalId
- ).selected_attribute_value_id = parseInt(ptavId);
+ let selectedIds = product.attribute_lines.find(ptal => ptal.id === ptalId).selected_attribute_value_ids;
+ if (multiIdsAllowed) {
+ const ptavID = parseInt(ptavId);
+ if (!selectedIds.includes(ptavID)){
+ selectedIds.push(ptavID);
+ } else {
+ selectedIds = selectedIds.filter(ptav => ptav !== ptavID);
+ }
+
+ } else {
+ selectedIds = [parseInt(ptavId)];
+ }
+ product.attribute_lines.find(ptal => ptal.id === ptalId).selected_attribute_value_ids = selectedIds;
this._checkExclusions(product);
if (this._isPossibleCombination(product)) {
const updatedValues = await this._updateCombination(product, product.quantity);
@@ -234,7 +245,7 @@ export class ProductConfiguratorDialog extends Component {
_updatePTAVCustomValue(productTmplId, ptavId, customValue) {
const product = this._findProduct(productTmplId);
product.attribute_lines.find(
- ptal => ptal.selected_attribute_value_id === ptavId
+ ptal => ptal.selected_attribute_value_ids.includes(ptavId)
).customValue = customValue;
}
@@ -328,7 +339,7 @@ export class ProductConfiguratorDialog extends Component {
* @return {Array} - The combination of the product.
*/
_getCombination(product) {
- return product.attribute_lines.map(ptal => ptal.selected_attribute_value_id);
+ return product.attribute_lines.flatMap(ptal => ptal.selected_attribute_value_ids);
}
/**
@@ -354,8 +365,8 @@ export class ProductConfiguratorDialog extends Component {
*/
_isPossibleCombination(product) {
return product.attribute_lines.every(ptal => !ptal.attribute_values.find(
- ptav => ptav.id === ptal.selected_attribute_value_id
- ).excluded);
+ ptav => ptal.selected_attribute_value_ids.includes(ptav.id)
+ )?.excluded);
}
/**
diff --git a/addons/sale_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.js b/addons/sale_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.js
index 908e3b27d45..adc1f271b52 100644
--- a/addons/sale_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.js
+++ b/addons/sale_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.js
@@ -17,7 +17,7 @@ export class ProductTemplateAttributeLine extends Component {
name: String,
display_type: {
type: String,
- validate: type => ["color", "pills", "radio", "select"].includes(type),
+ validate: type => ["color", "multi", "pills", "radio", "select"].includes(type),
},
},
},
@@ -35,7 +35,7 @@ export class ProductTemplateAttributeLine extends Component {
},
},
},
- selected_attribute_value_id: Number,
+ selected_attribute_value_ids: { type: Array, element: Number },
create_variant: {
type: String,
validate: type => ["always", "dynamic", "no_variant"].includes(type),
@@ -54,7 +54,7 @@ export class ProductTemplateAttributeLine extends Component {
*/
updateSelectedPTAV(event) {
this.env.updateProductTemplateSelectedPTAV(
- this.props.productTmplId, this.props.id, event.target.value
+ this.props.productTmplId, this.props.id, event.target.value, this.props.attribute.display_type == 'multi'
);
}
@@ -65,7 +65,7 @@ export class ProductTemplateAttributeLine extends Component {
*/
updateCustomValue(event) {
this.env.updatePTAVCustomValue(
- this.props.productTmplId, this.props.selected_attribute_value_id, event.target.value
+ this.props.productTmplId, this.props.selected_attribute_value_ids[0], event.target.value
);
}
@@ -76,11 +76,12 @@ export class ProductTemplateAttributeLine extends Component {
/**
* Return template name to use by checking the display type in the props.
*
- * Each attribute line can have one of this four display types:
+ * Each attribute line can have one of this five display types:
* - 'Color' : Display each attribute as a circle filled with said color.
* - 'Pills' : Display each attribute as a rectangle-shaped element.
* - 'Radio' : Display each attribute as a radio element.
* - 'Select' : Display each attribute in a selection tag.
+ * - 'Multi' : Display each attribute in a multi-checkbox tag.
*
* @return {String} - The template name to use.
*/
@@ -88,6 +89,8 @@ export class ProductTemplateAttributeLine extends Component {
switch(this.props.attribute.display_type) {
case 'color':
return 'saleProductConfigurator.ptav-color';
+ case 'multi':
+ return 'saleProductConfigurator.ptav-multi';
case 'pills':
return 'saleProductConfigurator.ptav-pills';
case 'radio':
@@ -126,7 +129,7 @@ export class ProductTemplateAttributeLine extends Component {
*/
isSelectedPTAVCustom() {
return this.props.attribute_values.find(
- ptav => this.props.selected_attribute_value_id === ptav.id
- ).is_custom;
+ ptav => this.props.selected_attribute_value_ids.includes(ptav.id)
+ )?.is_custom;
}
}
diff --git a/addons/sale_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.xml b/addons/sale_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.xml
index c26b71a5fa4..e9199c2d881 100644
--- a/addons/sale_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.xml
+++ b/addons/sale_product_configurator/static/src/js/product_template_attribute_line/product_template_attribute_line.xml
@@ -29,7 +29,7 @@
t-as="ptav"
t-key="ptav.id"
t-att-value="ptav.id"
- t-att-selected="ptav.id === this.props.selected_attribute_value_id"
+ t-att-selected="this.props.selected_attribute_value_ids.includes(ptav.id)"
t-out="getPTAVSelectName(ptav)"
t-att-class="{ 'css_not_available': ptav.excluded }"/>
@@ -54,7 +54,7 @@
class="form-check-input"
t-att-id="ptav.id"
t-att-value="ptav.id"
- t-att-checked="ptav.id === this.props.selected_attribute_value_id"
+ t-att-checked="this.props.selected_attribute_value_ids.includes(ptav.id)"
t-att-name="'ptal-' + this.props.id"
t-on-change="updateSelectedPTAV"/>
@@ -64,7 +64,7 @@
+
+
+
diff --git a/addons/sale_product_configurator/static/src/js/sale_product_field.js b/addons/sale_product_configurator/static/src/js/sale_product_field.js
index d7c9e4a0c3a..b604e3c7fb3 100644
--- a/addons/sale_product_configurator/static/src/js/sale_product_field.js
+++ b/addons/sale_product_configurator/static/src/js/sale_product_field.js
@@ -12,7 +12,7 @@ async function applyProduct(record, product) {
const contextRecords = [];
for (const ptal of product.attribute_lines) {
const selectedCustomPTAV = ptal.attribute_values.find(
- ptav => ptav.is_custom && ptav.id === ptal.selected_attribute_value_id
+ ptav => ptav.is_custom && ptal.selected_attribute_value_ids.includes(ptav.id)
);
if (selectedCustomPTAV) {
contextRecords.push({
@@ -27,7 +27,7 @@ async function applyProduct(record, product) {
const noVariantPTAVIds = product.attribute_lines.filter(
ptal => ptal.create_variant === "no_variant" && ptal.attribute_values.length > 1
- ).map(ptal => ptal.selected_attribute_value_id);
+ ).flatMap(ptal => ptal.selected_attribute_value_ids);
await Promise.all(proms);
await record.update({
diff --git a/addons/website_sale/views/product_attribute_views.xml b/addons/website_sale/views/product_attribute_views.xml
index 3715edf0692..0069a7fba5d 100644
--- a/addons/website_sale/views/product_attribute_views.xml
+++ b/addons/website_sale/views/product_attribute_views.xml
@@ -6,9 +6,11 @@
product.attribute
-
-
-
+
+
+
+
+
diff --git a/addons/website_sale_comparison/models/website_sale_comparison.py b/addons/website_sale_comparison/models/website_sale_comparison.py
index a74cd358b81..da57a332e55 100644
--- a/addons/website_sale_comparison/models/website_sale_comparison.py
+++ b/addons/website_sale_comparison/models/website_sale_comparison.py
@@ -20,9 +20,13 @@ class ProductAttribute(models.Model):
_inherit = 'product.attribute'
_order = 'category_id, sequence, id'
- category_id = fields.Many2one('product.attribute.category', string="Category", index=True,
- help="Set a category to regroup similar attributes under "
- "the same section in the Comparison page of eCommerce")
+ category_id = fields.Many2one(
+ comodel_name='product.attribute.category',
+ string="eCommerce Category",
+ index=True,
+ help="Set a category to regroup similar attributes under the same section in the Comparison"
+ " page of eCommerce.",
+ )
class ProductTemplateAttributeLine(models.Model):
diff --git a/addons/website_sale_comparison/views/website_sale_comparison_view.xml b/addons/website_sale_comparison/views/website_sale_comparison_view.xml
index e5ca824dfdf..1c95672bde3 100644
--- a/addons/website_sale_comparison/views/website_sale_comparison_view.xml
+++ b/addons/website_sale_comparison/views/website_sale_comparison_view.xml
@@ -45,12 +45,12 @@
product.attribute.form.inherit
product.attribute
-
+
-
+
-
+