[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:
co-authored by
Juhil Somaiya
parent
22a11a6f4d
commit
30d3ac79c7
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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())>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) > 2">
|
||||
@@ -119,19 +133,23 @@
|
||||
</div>
|
||||
</td>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="specs.keys()" t-as="spec">
|
||||
<t t-if="len(specs.keys())>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) > 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>
|
||||
|
||||
Reference in New Issue
Block a user