diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index 27eeb1ec3a9..012f41abc0b 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -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: diff --git a/addons/sale/views/variant_templates.xml b/addons/sale/views/variant_templates.xml index 8e712f4e1d5..ba54e1fc106 100644 --- a/addons/sale/views/variant_templates.xml +++ b/addons/sale/views/variant_templates.xml @@ -59,7 +59,7 @@

-

+

- - - :
+ + + : + + , + +

diff --git a/addons/website_sale_comparison/controllers/main.py b/addons/website_sale_comparison/controllers/main.py index f40ea82c865..c977063665f 100644 --- a/addons/website_sale_comparison/controllers/main.py +++ b/addons/website_sale_comparison/controllers/main.py @@ -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), } diff --git a/addons/website_sale_comparison/models/website_sale_comparison.py b/addons/website_sale_comparison/models/website_sale_comparison.py index 0cf2c04e950..a74cd358b81 100644 --- a/addons/website_sale_comparison/models/website_sale_comparison.py +++ b/addons/website_sale_comparison/models/website_sale_comparison.py @@ -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 diff --git a/addons/website_sale_comparison/static/tests/tours/website_sale_comparison.js b/addons/website_sale_comparison/static/tests/tours/website_sale_comparison.js index 8c115eba011..0dc5bfb88b6 100644 --- a/addons/website_sale_comparison/static/tests/tours/website_sale_comparison.js +++ b/addons/website_sale_comparison/static/tests/tours/website_sale_comparison.js @@ -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 () {}, }, ]); diff --git a/addons/website_sale_comparison/tests/test_website_sale_comparison.py b/addons/website_sale_comparison/tests/test_website_sale_comparison.py index 7cba6d9caf4..21e1db939d7 100644 --- a/addons/website_sale_comparison/tests/test_website_sale_comparison.py +++ b/addons/website_sale_comparison/tests/test_website_sale_comparison.py @@ -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]) + ])) + ])), + ])) diff --git a/addons/website_sale_comparison/views/website_sale_comparison_template.xml b/addons/website_sale_comparison/views/website_sale_comparison_template.xml index 20f579ff108..0d00ca84e06 100644 --- a/addons/website_sale_comparison/views/website_sale_comparison_template.xml +++ b/addons/website_sale_comparison/views/website_sale_comparison_template.xml @@ -34,23 +34,36 @@
- - + +

Specifications for

- - - - + + + + - - + + + + + + @@ -89,8 +102,9 @@

Compare Products

+ + Uncategorized +
+
- or - + + or + +
+ + , +
+ - - - - - - + + + + + + + + + + - - - - - +
+ @@ -119,19 +133,23 @@
Uncategorized
+ + , + +
-
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 ffce1b53c78..426c3970795 100644 --- a/addons/website_sale_comparison/views/website_sale_comparison_view.xml +++ b/addons/website_sale_comparison/views/website_sale_comparison_view.xml @@ -5,9 +5,10 @@ product.attribute.category.tree product.attribute.category - - + + +