[IMP] product, *: allow multiple lines for the same attribute

* = sale, website_sale, website_sale_comparison

Purpose
=======

This allows to handle use cases such as grape varieties for wine products where
multiple values need to be set for the same attribute, but they are all
informative and should not generate a choice or variants.

The general logic that existed previously is kept:
- multiple attribute lines on a product behave as an AND between them
- multiple values on a line behave as an OR between them

Example
=======

To generate 2 variants, with one possible value for each:
Line A: val 1, val 2

To generate 1 variant, with both informative values on it:
Line A: val 1
Line A: val 2

Technically
===========

- remove the constraints that prevented this use case from working since the
refactoring done in the previous commit removed the ambiguity when two
different lines have the same value
- update the product and comparison views accordingly

Extra
=====

- make the attribute category translatable and improve its list view

Final part of task-1912579

PR: #32946

Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>


Co-authored-by: Juhil Somaiya <jso@odoo.com>
Co-authored-by: Sébastien Theys <seb@odoo.com>
This commit is contained in:
Sébastien Theys
2019-08-26 13:01:14 +00:00
co-authored by Juhil Somaiya
parent 22a11a6f4d
commit 30d3ac79c7
11 changed files with 267 additions and 68 deletions
@@ -339,12 +339,6 @@ class ProductTemplate(models.Model):
raise ValidationError(_('The default Unit of Measure and the purchase Unit of Measure must be in the same category.'))
return True
@api.constrains('attribute_line_ids')
def _check_attribute_line(self):
if any(len(template.attribute_line_ids) != len(template.attribute_line_ids.mapped('attribute_id')) for template in self):
raise ValidationError(_('You cannot define two attribute lines for the same attribute.'))
return True
@api.onchange('uom_id')
def _onchange_uom_id(self):
if self.uom_id:
+2 -2
View File
@@ -59,7 +59,7 @@
<input type="radio"
t-attf-class="js_variant_change radio_input #{ptal.attribute_id.create_variant}"
t-att-checked="ptav in combination"
t-att-name="'attribute-%s-%s' % (product.id, ptal.attribute_id.id)"
t-att-name="'ptal-%s' % ptal.id"
t-att-value="ptav.id"
t-att-data-value_id="ptav.id"
t-att-data-value_name="ptav.name"
@@ -94,7 +94,7 @@
<input type="radio"
t-attf-class="js_variant_change #{ptal.attribute_id.create_variant}"
t-att-checked="ptav in combination"
t-att-name="'attribute-%s-%s' % (product.id, ptal.attribute_id.id)"
t-att-name="'ptal-%s' % ptal.id"
t-att-value="ptav.id"
t-att-title="ptav.name"
t-att-data-value_id="ptav.id"
+1
View File
@@ -14,3 +14,4 @@ from . import website
from . import res_config_settings
from . import digest
from . import res_company
from . import product_attribute
@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import OrderedDict
from odoo import models
class ProductTemplateAttributeLine(models.Model):
_inherit = 'product.template.attribute.line'
def _prepare_single_value_for_display(self):
"""On the product page group together the attribute lines that concern
the same attribute and that have only one value each.
Indeed those are considered informative values, they do not generate
choice for the user, so they are displayed below the configurator.
The returned attributes are ordered as they appear in `self`, so based
on the order of the attribute lines.
"""
single_value_lines = self.filtered(lambda ptal: len(ptal.value_ids) == 1)
single_value_attributes = OrderedDict([(pa, self.env['product.template.attribute.line']) for pa in single_value_lines.attribute_id])
for ptal in single_value_lines:
single_value_attributes[ptal.attribute_id] |= ptal
return single_value_attributes
+8 -4
View File
@@ -528,12 +528,16 @@
<hr t-if="product.description_sale" />
<div>
<p t-field="product.description_sale" class="text-muted mt-3" placeholder="A short description that will also appear on documents." />
<div class="o_not_editable" id="product_attributes_simple">
<div id="product_attributes_simple">
<hr t-if="sum([(1 if len(l.value_ids)==1 else 0) for l in product.attribute_line_ids])"/>
<p class="text-muted">
<t t-foreach="product.attribute_line_ids.sorted(key=lambda x: x.attribute_id.sequence)" t-as="variant_id">
<t t-if="len(variant_id.value_ids)==1">
<span t-field="variant_id.attribute_id" />: <span t-field="variant_id.value_ids[0].name" /><br /></t>
<t t-set="single_value_attributes" t-value="product.valid_product_template_attribute_line_ids._prepare_single_value_for_display()"/>
<t t-foreach="single_value_attributes" t-as="attribute">
<span t-field="attribute.name"/>:
<t t-foreach="single_value_attributes[attribute]" t-as="ptal">
<span t-field="ptal.product_template_value_ids._only_active().name"/><t t-if="not ptal_last">, </t>
</t>
<br/>
</t>
</p>
</div>
@@ -1,9 +1,8 @@
# -*- coding: utf-8 -*-
from odoo import http, _
from odoo import http
from odoo.http import request
from odoo.addons.website_sale.controllers.main import WebsiteSale
import json
from collections import OrderedDict
class WebsiteSaleProductComparison(WebsiteSale):
@@ -17,21 +16,6 @@ class WebsiteSaleProductComparison(WebsiteSale):
# use search to check read access on each record/ids
products = request.env['product.product'].search([('id', 'in', product_ids)])
values['products'] = products.with_context(display_default_code=False)
res = OrderedDict()
attrs = products.mapped('product_tmpl_id.valid_product_template_attribute_line_ids.attribute_id').filtered(lambda x: x.create_variant)
for attr in attrs.sorted(lambda att: (att.category_id.sequence, att.sequence)):
cat_name = attr.category_id.name or _('Uncategorized')
res.setdefault(cat_name, OrderedDict()).setdefault(attr.name, [' - '] * len(products))
for num, product in enumerate(products):
for var in product.product_tmpl_id.valid_product_template_attribute_line_ids.filtered(lambda x: x.attribute_id.create_variant):
cat_name = var.attribute_id.category_id.name or _('Uncategorized')
att_name = var.attribute_id.name
val = product.product_template_attribute_value_ids.product_attribute_value_id.filtered(lambda x: x.attribute_id == var.attribute_id)
if val:
res[cat_name][att_name][num] = val[0].name
values['specs'] = res
values['compute_currency'] = self._get_compute_currency_and_context(products[:1].product_tmpl_id)[0]
return request.render("website_sale_comparison.product_compare", values)
@http.route(['/shop/get_product_data'], type='json', auth="public", website=True)
@@ -39,7 +23,6 @@ class WebsiteSaleProductComparison(WebsiteSale):
ret = {}
pricelist_context, pricelist = self._get_pricelist_context()
prods = request.env['product.product'].with_context(pricelist_context, display_default_code=False).search([('id', 'in', product_ids)])
compute_currency = self._get_compute_currency(pricelist, prods[:1].product_tmpl_id)
if cookies is not None:
ret['cookies'] = json.dumps(request.env['product.product'].search([('id', 'in', list(set(product_ids + cookies)))]).ids)
@@ -49,7 +32,7 @@ class WebsiteSaleProductComparison(WebsiteSale):
ret[prod.id] = {
'render': request.env['ir.ui.view'].render_template(
"website_sale_comparison.product_product",
{'compute_currency': compute_currency, 'product': prod, 'website': request.website}
{'product': prod, 'website': request.website}
),
'product': dict(id=prod.id, name=prod.name, display_name=prod.display_name),
}
@@ -1,31 +1,80 @@
# -*- coding: utf-8 -*-
from collections import OrderedDict
from odoo import fields, models, _
from odoo import fields, models
class ProductAttributeCategory(models.Model):
_name = "product.attribute.category"
_description = "Product Attribute Category"
_order = 'sequence'
_order = 'sequence, id'
name = fields.Char("Category Name", required=True)
sequence = fields.Integer("Sequence", default=10)
name = fields.Char("Category Name", required=True, translate=True)
sequence = fields.Integer("Sequence", default=10, index=True)
attribute_ids = fields.One2many('product.attribute', 'category_id', string="Related Attributes", domain="[('category_id', '=', False)]")
class ProductAttribute(models.Model):
_inherit = 'product.attribute'
_order = 'category_id, sequence, id'
category_id = fields.Many2one('product.attribute.category', string="Category",
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")
class ProductTemplate(models.Model):
_inherit = 'product.template'
class ProductTemplateAttributeLine(models.Model):
_inherit = 'product.template.attribute.line'
def get_variant_groups(self):
res = OrderedDict()
for var in self.valid_product_template_attribute_line_ids:
res.setdefault(var.attribute_id.category_id.name or _('Uncategorized'), []).append(var)
return res
def _prepare_categories_for_display(self):
"""On the product page group together the attribute lines that concern
attributes that are in the same category.
The returned categories are ordered following their default order.
:return: OrderedDict [{
product.attribute.category: [product.template.attribute.line]
}]
"""
attributes = self.attribute_id
categories = OrderedDict([(cat, self.env['product.template.attribute.line']) for cat in attributes.category_id.sorted()])
if any(not pa.category_id for pa in attributes):
# category_id is not required and the mapped does not return empty
categories[self.env['product.attribute.category']] = self.env['product.template.attribute.line']
for ptal in self:
categories[ptal.attribute_id.category_id] |= ptal
return categories
class ProductProduct(models.Model):
_inherit = 'product.product'
def _prepare_categories_for_display(self):
"""On the comparison page group on the same line the values of each
product that concern the same attributes, and then group those
attributes per category.
The returned categories are ordered following their default order.
:return: OrderedDict [{
product.attribute.category: OrderedDict [{
product.attribute: OrderedDict [{
product: [product.template.attribute.value]
}]
}]
}]
"""
attributes = self.product_tmpl_id.valid_product_template_attribute_line_ids._without_no_variant_attributes().attribute_id.sorted()
categories = OrderedDict([(cat, OrderedDict()) for cat in attributes.category_id.sorted()])
if any(not pa.category_id for pa in attributes):
# category_id is not required and the mapped does not return empty
categories[self.env['product.attribute.category']] = OrderedDict()
for pa in attributes:
categories[pa.category_id][pa] = OrderedDict([(
product,
product.product_template_attribute_value_ids.filtered(lambda ptav: ptav.attribute_id == pa)
) for product in self])
return categories
@@ -239,7 +239,7 @@ odoo.define('website_sale_comparison.tour_comparison', function (require) {
},
{
content: "check there is the correct attribute",
trigger: '.Uncategorized:contains("color"):contains("red")',
trigger: '.o_ws_category_0:contains("color"):contains("red")',
run: function () {},
},
]);
@@ -1,6 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import OrderedDict
from lxml import etree
import odoo.tests
@@ -60,5 +63,125 @@ class TestWebsiteSaleComparison(odoo.tests.TransactionCase):
@odoo.tests.tagged('post_install', '-at_install')
class TestUi(odoo.tests.HttpCase):
def setUp(self):
super(TestUi, self).setUp()
self.template_margaux = self.env['product.template'].create({
'name': "Château Margaux",
'website_published': True,
'list_price': 0,
})
self.attribute_varieties = self.env['product.attribute'].create({
'name': 'Grape Varieties',
'sequence': 2,
})
self.attribute_vintage = self.env['product.attribute'].create({
'name': 'Vintage',
'sequence': 1,
})
self.values_varieties = self.env['product.attribute.value'].create({
'name': n,
'attribute_id': self.attribute_varieties.id,
'sequence': i,
} for i, n in enumerate(['Cabernet Sauvignon', 'Merlot', 'Cabernet Franc', 'Petit Verdot']))
self.values_vintage = self.env['product.attribute.value'].create({
'name': n,
'attribute_id': self.attribute_vintage.id,
'sequence': i,
} for i, n in enumerate(['2018', '2017', '2016', '2015']))
self.attribute_line_varieties = self.env['product.template.attribute.line'].create([{
'product_tmpl_id': self.template_margaux.id,
'attribute_id': self.attribute_varieties.id,
'value_ids': [(6, 0, v.ids)],
} for v in self.values_varieties])
self.attribute_line_vintage = self.env['product.template.attribute.line'].create({
'product_tmpl_id': self.template_margaux.id,
'attribute_id': self.attribute_vintage.id,
'value_ids': [(6, 0, self.values_vintage.ids)],
})
self.variants_margaux = self.template_margaux._get_possible_variants_sorted()
for variant, price in zip(self.variants_margaux, [487.32, 394.05, 532.44, 1047.84]):
variant.product_template_attribute_value_ids.filtered(lambda ptav: ptav.attribute_id == self.attribute_vintage).price_extra = price
def test_01_admin_tour_product_comparison(self):
self.start_tour("/", 'product_comparison', login='admin')
def test_02_attribute_multiple_lines(self):
# Case product page with "Product attributes table" disabled (website_sale standard case)
self.env['website'].viewref('website_sale_comparison.product_attributes_body').active = False
res = self.url_open('/shop/product/%d' % self.template_margaux.id)
self.assertEqual(res.status_code, 200)
root = etree.fromstring(res.content, etree.HTMLParser())
p = root.xpath('//div[@id="product_attributes_simple"]/p')[0]
text = etree.tostring(p, encoding='unicode', method='text')
self.assertEqual(text.replace(' ', '').replace('\n', ''), "GrapeVarieties:CabernetSauvignon,Merlot,CabernetFranc,PetitVerdot")
# Case product page with "Product attributes table" enabled
self.env['website'].viewref('website_sale_comparison.product_attributes_body').active = True
res = self.url_open('/shop/product/%d' % self.template_margaux.id)
self.assertEqual(res.status_code, 200)
root = etree.fromstring(res.content, etree.HTMLParser())
tr_vintage = root.xpath('//div[@id="product_specifications"]//tr')[0]
text_vintage = etree.tostring(tr_vintage, encoding='unicode', method='text')
self.assertEqual(text_vintage.replace(' ', '').replace('\n', ''), "Vintage2018or2017or2016or2015")
tr_varieties = root.xpath('//div[@id="product_specifications"]//tr')[1]
text_varieties = etree.tostring(tr_varieties, encoding='unicode', method='text')
self.assertEqual(text_varieties.replace(' ', '').replace('\n', ''), "GrapeVarietiesCabernetSauvignon,Merlot,CabernetFranc,PetitVerdot")
# Case compare page
res = self.url_open('/shop/compare/?products=%s' % ','.join(str(id) for id in self.variants_margaux.ids))
self.assertEqual(res.status_code, 200)
root = etree.fromstring(res.content, etree.HTMLParser())
table = root.xpath('//table[@id="o_comparelist_table"]')[0]
products = table.xpath('//a[@class="o_product_comparison_table"]')
self.assertEqual(len(products), 4)
for product, name in zip(products, ['ChâteauMargaux(2018)', 'ChâteauMargaux(2017)', 'ChâteauMargaux(2016)', 'ChâteauMargaux(2015)']):
text = etree.tostring(product, encoding='unicode', method='text')
self.assertEqual(text.replace(' ', '').replace('\n', ''), name)
tr_vintage = table.xpath('tbody/tr')[0]
text_vintage = etree.tostring(tr_vintage, encoding='unicode', method='text')
self.assertEqual(text_vintage.replace(' ', '').replace('\n', ''), "Vintage2018201720162015")
tr_varieties = table.xpath('tbody/tr')[1]
text_varieties = etree.tostring(tr_varieties, encoding='unicode', method='text')
self.assertEqual(text_varieties.replace(' ', '').replace('\n', ''), "GrapeVarieties" + 4 * "CabernetSauvignon,Merlot,CabernetFranc,PetitVerdot")
def test_03_category_order(self):
"""Test that categories are shown in the correct order when the
attributes are in a different order."""
category_vintage = self.env['product.attribute.category'].create({
'name': 'Vintage',
'sequence': 2,
})
category_varieties = self.env['product.attribute.category'].create({
'name': 'Varieties',
'sequence': 1,
})
self.attribute_vintage.category_id = category_vintage
self.attribute_varieties.category_id = category_varieties
prep_categories = self.template_margaux.valid_product_template_attribute_line_ids._prepare_categories_for_display()
self.assertEqual(prep_categories, OrderedDict([
(category_varieties, self.attribute_line_varieties),
(category_vintage, self.attribute_line_vintage),
]))
prep_categories = self.variants_margaux[0]._prepare_categories_for_display()
self.assertEqual(prep_categories, OrderedDict([
(category_varieties, OrderedDict([
(self.attribute_varieties, OrderedDict([
(self.template_margaux.product_variant_id, self.attribute_line_varieties.product_template_value_ids)
]))
])),
(category_vintage, OrderedDict([
(self.attribute_vintage, OrderedDict([
(self.template_margaux.product_variant_id, self.attribute_line_vintage.product_template_value_ids[0])
]))
])),
]))
@@ -34,23 +34,36 @@
<xpath expr="//div[@id='product_attributes_simple']" position="replace"/>
<xpath expr="//div[@id='product_full_description']" position="after">
<section class="container" id="product_full_spec">
<t t-set="atts" t-value="product.get_variant_groups()"/>
<t t-if='len(atts)'>
<t t-set="categories" t-value="product.valid_product_template_attribute_line_ids._prepare_categories_for_display()"/>
<t t-if="categories">
<h3 class="text-center mb32">Specifications for <t t-esc="product.name"/></h3>
<div class="row">
<div class='col-lg-8 offset-lg-2' id='product_specifications'>
<table class='table table-striped table-sm table-hover'>
<t t-foreach="atts.keys()" t-as="spec">
<t t-if="len(atts.keys())&gt;1">
<tr class="clickable" data-toggle="collapse" t-att-data-target="'.'+spec.split()[0]">
<th class="text-left" t-att-colspan="2"><t t-esc="spec"/></th>
<t t-foreach="categories" t-as="category">
<t t-if="len(categories) > 1">
<tr class="clickable" data-toggle="collapse" t-att-data-target="'.o_ws_category_%d' % category.id">
<th class="text-left" t-att-colspan="2">
<span t-if="category" t-field="category.name"/>
<span t-else="">Uncategorized</span>
</th>
</tr>
</t>
<tr t-foreach="atts[spec]" t-as="att" t-att-class="'collapse show ' + spec.split()[0]">
<td t-esc="att.attribute_id.name"/>
<tr t-foreach="categories[category].filtered(lambda l: len(l.value_ids) > 1)" t-as="ptal" t-att-class="'collapse show o_ws_category_%d' % category.id">
<td><span t-field="ptal.attribute_id.name"/></td>
<td>
<t t-set='or_separator'>or</t>
<t t-esc="(' %s ' % or_separator).join(att.value_ids.mapped('name'))"/>
<t t-foreach="ptal.value_ids" t-as="pav">
<span t-field="pav.name"/><t t-if="not pav_last"> or</t>
</t>
</td>
</tr>
<t t-set="single_value_attributes" t-value="categories[category]._prepare_single_value_for_display()"/>
<tr t-foreach="single_value_attributes" t-as="attribute" t-att-class="'collapse show o_ws_category_%d' % category.id">
<td><span t-field="attribute.name"/></td>
<td>
<t t-foreach="single_value_attributes[attribute]" t-as="ptal">
<span t-field="ptal.product_template_value_ids._only_active().name"/><t t-if="not ptal_last">, </t>
</t>
</td>
</tr>
</t>
@@ -89,8 +102,9 @@
<section class="container">
<h3>Compare Products</h3>
<table class="table table-bordered table-hover text-center mt16 table-comparator" id="o_comparelist_table">
<t t-set="categories" t-value="products._prepare_categories_for_display()"/>
<thead>
<td t-if="specs.keys()" class='td-top-left'/>
<td t-if="len(categories)" class='td-top-left'/>
<td t-foreach="products" t-as="product">
<t t-set="combination_info" t-value="product._get_combination_info_variant()"/>
<a href="#" t-att-data-product_product_id="product.id" class="o_comparelist_remove" t-if="len(products) &gt; 2">
@@ -119,19 +133,23 @@
</div>
</td>
</thead>
<tbody>
<t t-foreach="specs.keys()" t-as="spec">
<t t-if="len(specs.keys())&gt;1">
<tr class="clickable" data-toggle="collapse" t-att-data-target="'.'+spec.split()[0]">
<th class="text-left" t-att-colspan="len(products) + 1"><i class="fa fa-chevron-circle-down o_product_comparison_collpase" role="img" aria-label="Collapse" title="Collapse"></i><t t-esc="spec"/></th>
<tbody>
<t t-foreach="categories" t-as="category">
<t t-if="len(categories) &gt; 1">
<tr class="clickable" data-toggle="collapse" t-att-data-target="'.o_ws_category_%d' % category.id">
<th class="text-left" t-att-colspan="len(products) + 1"><i class="fa fa-chevron-circle-down o_product_comparison_collpase" role="img" aria-label="Collapse" title="Collapse"></i><span t-if="category" t-field="category.name"/><span t-else="">Uncategorized</span></th>
</tr>
</t>
<tr t-foreach="categories[category]" t-as="attribute" t-att-class="'collapse show o_ws_category_%d' % category.id">
<td><span t-field="attribute.name"/></td>
<td t-foreach="categories[category][attribute]" t-as="product">
<t t-foreach="categories[category][attribute][product]" t-as="ptav">
<span t-field="ptav.name"/><t t-if="not ptav_last">, </t>
</t>
</td>
</tr>
</t>
<tr t-foreach="specs[spec].keys()" t-as="att" t-att-class="'collapse show ' + spec.split()[0]">
<td t-esc="att"/>
<td t-foreach="specs[spec][att]" t-as="val"><t t-esc="val"/></td>
</tr>
</t>
</tbody>
</tbody>
</table>
</section>
</div>
@@ -5,9 +5,10 @@
<field name="name">product.attribute.category.tree</field>
<field name="model">product.attribute.category</field>
<field name="arch" type="xml">
<tree string="Product Attribute Category" editable="top">
<field name="sequence"/>
<tree string="Product Attribute Category" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="attribute_ids" widget="many2many_tags" options="{'no_create': True}"/>
</tree>
</field>
</record>