From 30d3ac79c7c61e2ecb9cfa5a7510f5798b4c325b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Theys?= Date: Mon, 26 Aug 2019 08:48:17 +0000 Subject: [PATCH] [IMP] product, *: allow multiple lines for the same attribute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * = 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) Co-authored-by: Juhil Somaiya Co-authored-by: Sébastien Theys --- addons/product/models/product_template.py | 6 - addons/sale/views/variant_templates.xml | 4 +- addons/website_sale/models/__init__.py | 1 + .../website_sale/models/product_attribute.py | 26 ++++ addons/website_sale/views/templates.xml | 12 +- .../controllers/main.py | 21 +-- .../models/website_sale_comparison.py | 73 +++++++++-- .../tests/tours/website_sale_comparison.js | 2 +- .../tests/test_website_sale_comparison.py | 123 ++++++++++++++++++ .../website_sale_comparison_template.xml | 62 +++++---- .../views/website_sale_comparison_view.xml | 5 +- 11 files changed, 267 insertions(+), 68 deletions(-) create mode 100644 addons/website_sale/models/product_attribute.py 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 - - + + +