From c78da8a80d687968bab1dcdff8e48ae904a4d1c2 Mon Sep 17 00:00:00 2001 From: Romain Derie Date: Thu, 28 Feb 2019 14:19:22 +0000 Subject: [PATCH 01/57] [FIX] website_links: fix multiple bugs + add tests Commit 6d67ce5b7c refactored the JS from multiple website modules including `website_links`. A few error needed to be fixed: - recent links would not appear anymore - graphs would not be displayed anymore - graph resize on tab change would not be fired This commit fixes it and also add some tests to be sure this module is now strongly tested. Note: tests are skipped for now as there is a race condition happening only with docker and this fix is getting urgent for Odoo.com closes odoo/odoo#31507 Signed-off-by: Romain Derie (rde) --- .../static/src/js/test_ui_flow.js | 82 +++++++++++++++++++ .../static/src/js/website_links.js | 5 +- .../static/src/js/website_links_charts.js | 18 ++-- addons/website_links/tests/__init__.py | 3 + addons/website_links/tests/test_ui.py | 18 ++++ .../views/website_links_template.xml | 1 + 6 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 addons/website_links/static/src/js/test_ui_flow.js create mode 100644 addons/website_links/tests/__init__.py create mode 100644 addons/website_links/tests/test_ui.py diff --git a/addons/website_links/static/src/js/test_ui_flow.js b/addons/website_links/static/src/js/test_ui_flow.js new file mode 100644 index 00000000000..4d8c7def0f9 --- /dev/null +++ b/addons/website_links/static/src/js/test_ui_flow.js @@ -0,0 +1,82 @@ +odoo.define('website_links.website_links_tour', function (require) { +'use strict'; + +var tour = require("web_tour.tour"); +var base = require("web_editor.base"); + +tour.register('website_links_tour', { + test: true, + url: '/r', + wait_for: base.ready() +}, + [ + // 1. Create a tracked URL + { + content: "check that existing links are shown", + trigger: '#o_website_links_recent_links .btn_shorten_url_clipboard', + run: function () {}, // it's a check + }, + { + content: "fill the form and submit it", + trigger: '#o_website_links_link_tracker_form input#url', + run: function () { + var url = window.location.host + '/aboutus'; + $('#o_website_links_link_tracker_form input#url').val(url); + $('.o_website_links_utm_forms input#campaign-select').val(1).change(); + $('.o_website_links_utm_forms input#channel-select').val(1).change(); + $('.o_website_links_utm_forms input#source-select').val(1).change(); + $('#btn_shorten_url').click(); + }, + }, + // 2. Visit it + { + content: "check that link was created and visit it", + // Depending of the speed of `requests.get` from `_get_title_from_url`, + // the link name will either be "About us" (page title) or + // "http://url/aboutus" (page url). + extra_trigger: '#o_website_links_recent_links .truncate_text:first():contains("About us"), #o_website_links_recent_links .truncate_text:first():contains("aboutus")', + trigger: '#o_website_links_link_tracker_form #generated_tracked_link:contains("/r/")', + run: function () { + window.location.href = $('#generated_tracked_link').text(); + }, + }, + { + content: "check that we landed on correct page with correct query strings", + trigger: '.s_title h1:containsExact("About us")', + run: function () { + var expectedUrl = "/aboutus?utm_campaign=Sale&utm_source=Search+engine&utm_medium=Website"; + if (window.location.pathname + window.location.search !== expectedUrl) { + console.error("The link was not correctly created."); + } + window.location.href = '/r'; + }, + }, + // 3. Check that counter got incremented and charts are correctly displayed + { + content: "visit link stats page", + trigger: '#o_website_links_recent_links a:containsExact("Stats"):first()', + }, + { + content: "check click number and ensure graphs are initialized", + extra_trigger: '.website_links_click_chart .title:contains("1 clicks")', + trigger: '.nvd3.nv-pieChart', + run: function () {}, // it's a check + }, + { + content: "click on Last Month tab", + trigger: '.o_website_links_chart .graph-tabs a:contains("Last Month")', + }, + { + content: "ensure tab is correctly resized", + trigger: '.nvd3.nv-lineChart', + run: function () { + var width = $('#last_month_charts .nvd3.nv-lineChart .nv-background rect').width(); + if (width < 50) { + console.error("The graphs are probably not resized on tab change."); + } + }, + }, + ] +); + +}); diff --git a/addons/website_links/static/src/js/website_links.js b/addons/website_links/static/src/js/website_links.js index f95e1033552..b9754b1bb2c 100644 --- a/addons/website_links/static/src/js/website_links.js +++ b/addons/website_links/static/src/js/website_links.js @@ -8,7 +8,6 @@ var sAnimations = require('website.content.snippets.animation'); var _t = core._t; var SelectBox = Widget.extend({ - xmlDependencies: ['/website_links/static/src/xml/recent_link.xml'], events: { 'change': '_onChange', }, @@ -107,6 +106,7 @@ var SelectBox = Widget.extend({ var RecentLinkBox = Widget.extend({ template: 'website_links.RecentLink', + xmlDependencies: ['/website_links/static/src/xml/recent_link.xml'], events: { 'click .btn_shorten_url_clipboard': '_toggleCopyButton', 'click .o_website_links_edit_code': '_editCode', @@ -460,6 +460,7 @@ sAnimations.registry.websiteLinks = sAnimations.Class.extend({ * @param {Event} ev */ _onFormSubmit: function (ev) { + var self = this; ev.preventDefault(); if ($('#btn_shorten_url').hasClass('btn-copy')) { @@ -511,7 +512,7 @@ sAnimations.registry.websiteLinks = sAnimations.Class.extend({ $('#generated_tracked_link').html(link.short_url); $('#generated_tracked_link').css('display', 'inline'); - this.recentLinks._addLink(link); + self.recentLinks._addLink(link); // Clean URL and UTM selects $('#campaign-select').select2('val', ''); diff --git a/addons/website_links/static/src/js/website_links_charts.js b/addons/website_links/static/src/js/website_links_charts.js index 08ea40f1bed..bd2e3ba2747 100644 --- a/addons/website_links/static/src/js/website_links_charts.js +++ b/addons/website_links/static/src/js/website_links_charts.js @@ -197,27 +197,27 @@ sAnimations.registry.websiteLinksCharts = sAnimations.Class.extend({ // Process all time line chart data var now = moment(); - self.charts.all_time_bar = new BarChart(beginDate, now, formattedClicksByDay); + self.charts.all_time_bar = new BarChart(this, beginDate, now, formattedClicksByDay); self.charts.all_time_bar.attachTo($('#all_time_clicks_chart')); // Process month line chart data beginDate = moment().subtract(30, 'days'); - self.charts.last_month_bar = new BarChart(beginDate, now, formattedClicksByDay); + self.charts.last_month_bar = new BarChart(this, beginDate, now, formattedClicksByDay); self.charts.last_month_bar.attachTo($('#last_month_clicks_chart')); // Process week line chart data beginDate = moment().subtract(7, 'days'); - self.charts.last_week_bar = new BarChart(beginDate, now, formattedClicksByDay); + self.charts.last_week_bar = new BarChart(this, beginDate, now, formattedClicksByDay); self.charts.last_week_bar.attachTo($('#last_week_clicks_chart')); // Process pie charts - self.charts.all_time_pie = new PieChart(_clicksByCountry); + self.charts.all_time_pie = new PieChart(this, _clicksByCountry); self.charts.all_time_pie.attachTo($('#all_time_countries_charts')); - self.charts.last_month_pie = new PieChart(_lastMonthClicksByCountry); + self.charts.last_month_pie = new PieChart(this, _lastMonthClicksByCountry); self.charts.last_month_pie.attachTo($('#last_month_countries_charts')); - self.charts.last_week_pie = new PieChart(_lastWeekClicksByCountry); + self.charts.last_week_pie = new PieChart(this, _lastWeekClicksByCountry); self.charts.last_week_pie.attachTo($('#last_week_countries_charts')); var rowWidth = $('#all_time_countries_charts').parent().width(); @@ -305,7 +305,11 @@ sAnimations.registry.websiteLinksCharts = sAnimations.Class.extend({ _onGraphTabClick: function (ev) { ev.preventDefault(); $('.graph-tabs li a').tab('show'); - _.chain(this.charts).pluck('chart').invoke('update'); // Force NVD3 to redraw the chart + + setTimeout(function () { + // Force NVD3 to redraw the chart + window.dispatchEvent(new Event('resize')); + }, 0); }, /** * @private diff --git a/addons/website_links/tests/__init__.py b/addons/website_links/tests/__init__.py new file mode 100644 index 00000000000..1e046233e6c --- /dev/null +++ b/addons/website_links/tests/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import test_ui diff --git a/addons/website_links/tests/test_ui.py b/addons/website_links/tests/test_ui.py new file mode 100644 index 00000000000..58a697ef7a1 --- /dev/null +++ b/addons/website_links/tests/test_ui.py @@ -0,0 +1,18 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import odoo.tests +import unittest + + +@odoo.tests.common.tagged('post_install', '-at_install') +class TestUi(odoo.tests.HttpCase): + + @unittest.skip("Race condition on runbot or on local with Docker. Merging the test skipped for history purpose as the tour has been reviewed.") + def test_01_test_ui(self): + self.env['link.tracker'].create({ + 'campaign_id': 2, + 'medium_id': 2, + 'source_id': 2, + 'url': self.env["ir.config_parameter"].sudo().get_param("web.base.url") + '/contactus', + }) + self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('website_links_tour')", "odoo.__DEBUG__.services['web_tour.tour'].tours.website_links_tour.ready", login="admin") diff --git a/addons/website_links/views/website_links_template.xml b/addons/website_links/views/website_links_template.xml index d69bddf7d63..aca362bc40c 100644 --- a/addons/website_links/views/website_links_template.xml +++ b/addons/website_links/views/website_links_template.xml @@ -6,6 +6,7 @@ diff --git a/addons/web/static/src/scss/fontawesome_overriden.scss b/addons/web/static/src/scss/fontawesome_overriden.scss index 5fde7a750cd..a234dbc9f81 100644 --- a/addons/web/static/src/scss/fontawesome_overriden.scss +++ b/addons/web/static/src/scss/fontawesome_overriden.scss @@ -1,36 +1,38 @@ // This is rtl language specific fix // It will override the font awesome symbols and flip them 180 degree -.fa { - &.fa-align-right, - &.fa-align-left, - &.fa-chevron-right, - &.fa-chevron-left, - &.fa-arrow-right, - &.fa-arrow-left, - &.fa-hand-o-right, - &.fa-hand-o-left, - &.fa-arrow-circle-right, - &.fa-arrow-circle-left, - &.fa-caret-right, - &.fa-caret-left, - &.fa-rotate-right, - &.fa-rotate-left, - &.fa-angle-double-right, - &.fa-angle-double-left, - &.fa-angle-right, - &.fa-angle-left, - &.fa-quote-right, - &.fa-quote-left, - &.fa-chevron-circle-right, - &.fa-chevron-circle-left, - &.fa-long-arrow-right, - &.fa-long-arrow-left, - &.fa-toggle-right, - &.fa-toggle-left, - &.fa-caret-square-o-right, - &.fa-arrow-circle-o-left, - &.fa-arrow-circle-o-right, - &.fa-caret-square-o-left { - transform: rotate(180deg); +.o_rtl { + .fa { + &.fa-align-right, + &.fa-align-left, + &.fa-chevron-right, + &.fa-chevron-left, + &.fa-arrow-right, + &.fa-arrow-left, + &.fa-hand-o-right, + &.fa-hand-o-left, + &.fa-arrow-circle-right, + &.fa-arrow-circle-left, + &.fa-caret-right, + &.fa-caret-left, + &.fa-rotate-right, + &.fa-rotate-left, + &.fa-angle-double-right, + &.fa-angle-double-left, + &.fa-angle-right, + &.fa-angle-left, + &.fa-quote-right, + &.fa-quote-left, + &.fa-chevron-circle-right, + &.fa-chevron-circle-left, + &.fa-long-arrow-right, + &.fa-long-arrow-left, + &.fa-toggle-right, + &.fa-toggle-left, + &.fa-caret-square-o-right, + &.fa-arrow-circle-o-left, + &.fa-arrow-circle-o-right, + &.fa-caret-square-o-left { + transform: rotate(180deg); + } } -} +} \ No newline at end of file diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index cb1184e3565..b65daaa58e7 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -65,9 +65,7 @@ - - - + From 37689b189ee904497eb0ce0097ecf013cac1a91c Mon Sep 17 00:00:00 2001 From: Nans Lefebvre Date: Tue, 12 Mar 2019 14:17:36 +0000 Subject: [PATCH 40/57] [FIX] payment: do not retry indefinitely to post process transactions Suppose that a payment transaction cannot be reconciled for any reason. Then _cron_post_process_after_done will try to process it every 10 minutes until the end of time. Moreover, if the user takes some manual action before _cron_post_process_after_done, even if in principle it would have been able to go through, then it is likely to fail, e.g. on invoice_open. Meaning that a transaction that could have been processed will become an undead transaction. To avoid that situation we set an arbitrary retry_limit_date set to 2 days, which seems a reasonnable compromise. It gives some time for the transaction to go through, but not exaggeratedly so. opw 1945953 closes odoo/odoo#31791 Signed-off-by: Toufik Benjaa (tbe) --- addons/payment/models/payment_acquirer.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/addons/payment/models/payment_acquirer.py b/addons/payment/models/payment_acquirer.py index 209c4556d7b..bcfcb2025bf 100644 --- a/addons/payment/models/payment_acquirer.py +++ b/addons/payment/models/payment_acquirer.py @@ -785,10 +785,13 @@ class PaymentTransaction(models.Model): def _cron_post_process_after_done(self): if not self: ten_minutes_ago = datetime.now() - relativedelta.relativedelta(minutes=10) + # we don't want to forever try to process a transaction that doesn't go through + retry_limit_date = datetime.now() - relativedelta.relativedelta(days=2) # we retrieve all the payment tx that need to be post processed self = self.search([('state', '=', 'done'), ('is_processed', '=', False), ('date', '<=', ten_minutes_ago), + ('date', '>=', retry_limit_date), ]) for tx in self: try: From 47016a1df354fe4620a5f7e7bd8a3fc38d2e40df Mon Sep 17 00:00:00 2001 From: Jorge Pinna Puissant Date: Tue, 12 Mar 2019 08:54:46 +0000 Subject: [PATCH 41/57] [FIX] portal: sent form without signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the portal, when the client signature is asked (for a SO for example). Before this commit, the client could send the form without really signing the document. Now, a warning is shown if the form is not signed. opw-1943702 closes odoo/odoo#31768 Signed-off-by: Sébastien Theys Co-authored-by: Sébastien Theys --- addons/portal/static/src/js/portal_signature.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/addons/portal/static/src/js/portal_signature.js b/addons/portal/static/src/js/portal_signature.js index 3c19b3b8f91..35c06d0c531 100644 --- a/addons/portal/static/src/js/portal_signature.js +++ b/addons/portal/static/src/js/portal_signature.js @@ -116,14 +116,21 @@ odoo.define('portal.signature_form', function (require){ base.ready().then(function () { $('.o_portal_signature_form').each(function () { + var hasBeenReset = false; var $elem = $(this); var form = new SignatureForm(null, $elem.data()); form.appendTo($elem); - }); - // Make the signature responsive when it is displayed in bootstrap modal. - // More precisely it is too small if this code is not here. - $('.o_portal_signature_form').parents('.modal').on('shown.bs.modal', function (ev) { - $('.o_portal_signature_form').trigger('resize'); + // Make the signature responsive when it is displayed in bootstrap modal. + // More precisely it is too small if this code is not here. + $elem.parents('.modal').on('shown.bs.modal', function (ev) { + $elem.trigger('resize'); + if (!hasBeenReset) { + // Reset it only the first time it is open to get correct + // size. After we want to keep its content on reopen. + hasBeenReset = true; + form.initSign(); + } + }); }); }); From 67990f91f197c6c17f2659e49651915512a0a047 Mon Sep 17 00:00:00 2001 From: Priyanka Kakadiya Date: Mon, 11 Mar 2019 17:35:31 +0000 Subject: [PATCH 42/57] [IMP] website_sale: adapt wishlist tour to product configurator fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this commit the flow of using wishlist with dynamic attributes was not tested, and it also wasn't tested when combinations were not possible. Those cases are now tested too. closes #29859 PR: #31752 task-1920065 Signed-off-by: Sébastien Theys --- addons/product/models/product.py | 2 + .../src/js/website_sale_tour_wishlist.js | 215 ++++++++++++++++++ 2 files changed, 217 insertions(+) diff --git a/addons/product/models/product.py b/addons/product/models/product.py index f3003452383..601a0da28ac 100644 --- a/addons/product/models/product.py +++ b/addons/product/models/product.py @@ -359,6 +359,8 @@ class ProductProduct(models.Model): # prefetched o2m have to be reloaded (because of active_test) # (eg. product.template: product_variant_ids) self.invalidate_cache() + # `_get_first_possible_variant_id` depends on variants active state + self.clear_caches() return res @api.multi diff --git a/addons/website_sale_wishlist/static/src/js/website_sale_tour_wishlist.js b/addons/website_sale_wishlist/static/src/js/website_sale_tour_wishlist.js index 8089d8fb3e7..62baefa5205 100644 --- a/addons/website_sale_wishlist/static/src/js/website_sale_tour_wishlist.js +++ b/addons/website_sale_wishlist/static/src/js/website_sale_tour_wishlist.js @@ -1,6 +1,7 @@ odoo.define('website_sale_wishlist.tour', function (require) { 'use strict'; +var rpc = require('web.rpc'); var tour = require("web_tour.tour"); var base = require("web_editor.base"); @@ -125,6 +126,220 @@ tour.register('shop_wishlist', { trigger: ":not(:has(.my_wish_quantity:visible))", run: function() {}, }, + // Test dynamic attributes + { + content: "Create a product with dynamic attribute and its values.", + trigger: 'body', + run: function () { + rpc.query({ + model: 'product.attribute', + method: 'create', + args: [{ + 'name': "color", + 'type': 'color', + 'create_variant': 'dynamic' + }], + }).then(function (attributeId) { + return rpc.query({ + model: 'product.template', + method: 'create', + args: [{ + 'name': "Bottle", + 'is_published': true, + 'attribute_line_ids': [[0, 0, { + 'attribute_id': attributeId, + 'value_ids': [ + [0, 0, { + 'name': "red", + 'attribute_id': attributeId, + }], + [0, 0, { + 'name': "blue", + 'attribute_id': attributeId, + }], + [0, 0, { + 'name': "black", + 'attribute_id': attributeId, + }], + ] + }]], + }], + }); + }).then(function () { + window.location.href = '/web/session/logout?redirect=/shop?search=Bottle'; + }); + }, + }, + { + content: "Add Bottle to wishlist from /shop", + extra_trigger: '.oe_product_cart:contains("Bottle")', + trigger: '.oe_product_cart:contains("Bottle") .o_add_wishlist', + }, + { + content: "Check that wishlist contains 1 item", + trigger: '.my_wish_quantity:contains(1)', + run: function () {}, + }, + { + content: "Click on product", + extra_trigger: '.oe_product_cart:contains("Bottle") .o_add_wishlist.disabled', + trigger: '.oe_product_cart a:contains("Bottle")', + }, + { + content: "Select Bottle with second variant from /product", + trigger: '.js_variant_change[data-value_name="blue"]', + }, + { + content: "Add product in wishlist", + extra_trigger: '#product_detail .o_add_wishlist_dyn:not(".disabled")', + trigger: '#product_detail .o_add_wishlist_dyn', + }, + { + content: "Select Bottle with third variant from /product", + trigger: '.js_variant_change[data-value_name="black"]', + }, + { + content: "Add product in wishlist", + extra_trigger: '#product_detail .o_add_wishlist_dyn:not(".disabled")', + trigger: '#product_detail .o_add_wishlist_dyn', + }, + { + content: "Check that wishlist contains 3 items and go to wishlist", + trigger: '.my_wish_quantity:contains(3)', + run: function () { + window.location.href = '/shop/wishlist'; + }, + }, + { + content: "Check wishlist contains first variant", + trigger: '#o_comparelist_table tr:contains("red")', + run: function () {}, + }, + { + content: "Check wishlist contains second variant", + trigger: '#o_comparelist_table tr:contains("blue")', + run: function () {}, + }, + { + content: "Check wishlist contains third variant, then go to login", + trigger: '#o_comparelist_table tr:contains("black")', + run: function () { + window.location.href = "/web/login"; + }, + }, + { + content: "Submit login as admin", + trigger: '.oe_login_form', + run: function () { + $('.oe_login_form input[name="login"]').val("admin"); + $('.oe_login_form input[name="password"]').val("admin"); + $('.oe_login_form input[name="redirect"]').val("/"); + $('.oe_login_form').submit(); + }, + }, + // Test one impossible combination while other combinations are possible + { + content: "Archive the first variant", + trigger: '#top_menu:contains("Mitchell Admin")', + run: function () { + rpc.query({ + model: 'product.product', + method: 'search', + args: [[['name', '=', "Bottle"]]], + }) + .then(function (productIds) { + return rpc.query({ + model: 'product.product', + method: 'write', + args: [productIds[0], {active: false}], + }); + }) + .then(function () { + window.location.href = '/web/session/logout?redirect=/shop?search=Bottle'; + }); + }, + }, + { + content: "Check there is wishlist button on product from /shop", + extra_trigger: '.js_sale', + trigger: '.oe_product_cart:contains("Bottle") .o_add_wishlist', + run: function () {}, + }, + { + content: "Click on product", + trigger: '.oe_product_cart a:contains("Bottle")', + }, + { + content: "Select Bottle with first variant (red) from /product", + trigger: '.js_variant_change[data-value_name="red"]', + }, + { + content: "Check there is no wishlist button when selecting impossible variant", + trigger: '#product_detail:not(:has(.o_add_wishlist))', + run: function () {}, + }, + { + content: "Select Bottle with second variant (blue) from /product", + trigger: '.js_variant_change[data-value_name="blue"]', + }, + { + content: "Click on wishlist when selecting a possible variant from /product", + trigger: '#product_detail .o_add_wishlist_dyn:not(.disabled)', + }, + { + content: "Check product added to wishlist and go to login", + trigger: '.my_wish_quantity:contains(1)', + run: function () { + window.location.href = "/web/login"; + }, + }, + { + content: "Submit login", + trigger: '.oe_login_form', + run: function () { + $('.oe_login_form input[name="login"]').val("admin"); + $('.oe_login_form input[name="password"]').val("admin"); + $('.oe_login_form input[name="redirect"]').val("/"); + $('.oe_login_form').submit(); + }, + }, + // test when all combinations are impossible + { + content: "Archive all variants", + trigger: '#top_menu:contains("Mitchell Admin")', + run: function () { + rpc.query({ + model: 'product.product', + method: 'search', + args: [[['name', '=', "Bottle"]]], + }) + .then(function (productIds) { + return rpc.query({ + model: 'product.product', + method: 'write', + args: [productIds, {active: false}], + }); + }) + .then(function () { + window.location.href = '/web/session/logout?redirect=/shop?search=Bottle'; + }); + } + }, + { + content: "Check that there is no wishlist button from /shop", + extra_trigger: '.js_sale', + trigger: '.oe_product_cart:contains("Bottle"):not(:has(.o_add_wishlist))', + run: function () {}, + }, + { + content: "Click on product", + trigger: '.oe_product_cart a:contains("Bottle")', + }, + { + content: "Check that there is no wishlist button from /product", + trigger: '#product_detail:not(:has(.o_add_wishlist_dyn))', + run: function () {}, + }, ] ); From 49db3d92e7ec583afea61556b0484b5347f4d777 Mon Sep 17 00:00:00 2001 From: XavierDo Date: Fri, 8 Mar 2019 12:32:55 +0000 Subject: [PATCH 43/57] [FIX] mail, test_mail: don't notify inactive users We don't want to notify inactive followers, especially in the case of oddobot that was considered as a inactive partner in _notify_compute_recipients. The computed recipient data for odoobot are (pid:2 active:False pshare:True notif:None) It was considered as a partner and notified by email. This fix simply removes inactive partner from partner to notify. closes odoo/odoo#31734 Signed-off-by: Toufik Benjaa (tbe) --- addons/mail/models/mail_message.py | 3 +++ addons/test_mail/tests/test_discuss.py | 14 ++++++++++++++ addons/test_mail/tests/test_mail_gateway.py | 14 +++++++++----- .../static/src/js/website_sale_tour_shop_mail.js | 11 +++++++++++ 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index f07af1c524f..b108f3d061e 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -1079,6 +1079,9 @@ class Message(models.Model): if pid and pid == author_id and not self.env.context.get('mail_notify_author'): # do not notify the author of its own messages continue if pid: + if active is False: + # avoid to notify inactive partner by email (odoobot) + continue pdata = {'id': pid, 'active': active, 'share': pshare, 'groups': groups} if notif == 'inbox': recipient_data['partners'].append(dict(pdata, notif=notif, type='user')) diff --git a/addons/test_mail/tests/test_discuss.py b/addons/test_mail/tests/test_discuss.py index 3f27a20f4b0..f182e7f6a55 100644 --- a/addons/test_mail/tests/test_discuss.py +++ b/addons/test_mail/tests/test_discuss.py @@ -96,6 +96,20 @@ class TestNotifications(BaseFunctionalTest, MockEmails): body='Test', message_type='comment', subtype='mail.mt_comment', partner_ids=[self.user_employee.partner_id.id]) + def test_inactive_follower(self): + # In some case odoobot is follower of a record. + # Even if it shouldn't be the case, we want to be sure that odoobot is not notified + self.test_record._message_subscribe(self.user_employee.partner_id.ids) + with self.assertNotifications(partner_employee=(1, 'inbox', 'unread')): + self.test_record.message_post( + body='Test', message_type='comment', subtype='mail.mt_comment') + self.user_employee.active = False + # at this point, partner is still active and would receive an email notification + self.user_employee.partner_id._write({'active': False}) + with self.assertNotifications(partner_employee=(0, '', '')): + self.test_record.message_post( + body='Test', message_type='comment', subtype='mail.mt_comment') + def test_set_message_done_user(self): with self.assertNotifications(partner_employee=(0, '', '')): message = self.test_record.message_post( diff --git a/addons/test_mail/tests/test_mail_gateway.py b/addons/test_mail/tests/test_mail_gateway.py index 9a8d9e6411f..1a796b07501 100644 --- a/addons/test_mail/tests/test_mail_gateway.py +++ b/addons/test_mail/tests/test_mail_gateway.py @@ -486,14 +486,18 @@ class TestMailgateway(BaseFunctionalTest, MockEmails): @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models', 'odoo.addons.mail.models.mail_mail') def test_private_discussion(self): """ Testing private discussion between partners. """ - msg1_pids = [self.env.user.partner_id.id, self.partner_1.id] + self.partner_2 = self.env['res.partner'].with_context(BaseFunctionalTest._test_context).create({ + 'name': 'Admin', + 'email': 'admin@agrolait.com', + }) + msg1_pids = [self.partner_1.id, self.partner_2.id] # Do: Raoul writes to Bert and Administrator, with a model specific by parameter that should not be taken into account msg1 = self.env['mail.thread'].sudo(self.user_employee).message_post(partner_ids=msg1_pids, subtype='mail.mt_comment', model='mail.test') # Test: message recipients msg = self.env['mail.message'].browse(msg1.id) - self.assertEqual(msg.partner_ids, self.env.user.partner_id | self.partner_1, + self.assertEqual(msg.partner_ids, self.partner_2 | self.partner_1, 'message_post: private discussion: incorrect recipients') self.assertEqual(msg.model, False, 'message_post: private discussion: parameter model not correctly ignored when having no res_id') @@ -510,7 +514,7 @@ class TestMailgateway(BaseFunctionalTest, MockEmails): # Test: message recipients self.assertEqual(msg2.author_id, self.partner_1, 'message_post: private discussion: wrong author through mailgateway based on email') - self.assertEqual(msg2.partner_ids, self.user_employee.partner_id | self.env.user.partner_id, + self.assertEqual(msg2.partner_ids, self.user_employee.partner_id | self.partner_2, 'message_post: private discussion: incorrect recipients when replying') # Do: Bert replies through chatter (is a customer) @@ -518,9 +522,9 @@ class TestMailgateway(BaseFunctionalTest, MockEmails): # Test: message recipients msg = self.env['mail.message'].browse(msg3.id) - self.assertEqual(msg.partner_ids, self.user_employee.partner_id | self.env.user.partner_id, + self.assertEqual(msg.partner_ids, self.user_employee.partner_id | self.partner_2, 'message_post: private discussion: incorrect recipients when replying') - self.assertEqual(msg.needaction_partner_ids, self.user_employee.partner_id | self.env.user.partner_id, + self.assertEqual(msg.needaction_partner_ids, self.user_employee.partner_id | self.partner_2, 'message_post: private discussion: incorrect notified recipients when replying') @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models', 'odoo.addons.mail.models.mail_mail') diff --git a/addons/website_sale/static/src/js/website_sale_tour_shop_mail.js b/addons/website_sale/static/src/js/website_sale_tour_shop_mail.js index c8343ef33e5..601f7a2759e 100644 --- a/addons/website_sale/static/src/js/website_sale_tour_shop_mail.js +++ b/addons/website_sale/static/src/js/website_sale_tour_shop_mail.js @@ -70,9 +70,20 @@ tour.register('shop_mail', { trigger: '.btn[name="action_quotation_send"]', extra_trigger: '.o_statusbar_status .btn-primary:contains("Sales Order")', }, + { + content: "Open recipients dropdown", + trigger: '.o_field_many2one[name="partner_ids"] .ui-autocomplete-input', + run: 'click', + }, + { + content: "Select azure interior", + trigger: '.ui-menu-item a:contains(Interior24)', + in_modal: false, + }, { content: "click Send email", trigger: '.btn[name="action_send_mail"]', + extra_trigger: '.o_badge_text:contains("Azure")', }, { content: "wait mail to be sent, and go see it", From 49ca43d75cb9a97642c820c2466d454f1ce604cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Rahir?= Date: Mon, 11 Mar 2019 10:42:08 +0000 Subject: [PATCH 44/57] [FIX] website_sale: filter fiscal positions on company v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sequel of cc54194e130d4a93d9afdd7aedc7d35be2e03fed. The mentioned fix only worked when the user was public. The problem arises when calling the function `_get_fpos_by_region()` in `sudo` without specifying the company and this happens, for instance, in every `onchange_partner_*` function of a sale order. We propose to add the `force_company` key in the context of the sale order to ensure the right company when selecting the fiscal position. closes odoo/odoo#31751 Signed-off-by: Rémi Rahir (rar) --- addons/website_sale/models/sale_order.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py index c98fb72deea..97e6a09a7a5 100644 --- a/addons/website_sale/models/sale_order.py +++ b/addons/website_sale/models/sale_order.py @@ -390,7 +390,7 @@ class Website(models.Model): # TODO cache partner_id session pricelist = self.env['product.pricelist'].browse(pricelist_id).sudo() so_data = self._prepare_sale_order_values(partner, pricelist) - sale_order = self.env['sale.order'].sudo().create(so_data) + sale_order = self.env['sale.order'].with_context(force_company=request.website.company_id.id).sudo().create(so_data) # set fiscal position if request.website.partner_id.id != partner.id: From fa4552926cae5a58ed26d8050a017b610bc5e709 Mon Sep 17 00:00:00 2001 From: Nicolas Lempereur Date: Thu, 7 Mar 2019 17:13:25 +0000 Subject: [PATCH 45/57] [FIX] website_sale_comparison: no compare variant=False When comparing product, there was already an exceptions that would not list the attributes if they were only "create_variant=False" type. But if we mix attribute create_variant False or not on a product, we could get an error. With this changeset, create_variant=False attribute are ignored also when mixed with create_variant=True attributes. opw-1946361 closes #31680 Signed-off-by: Nicolas Lempereur (nle) --- addons/website_sale_comparison/controllers/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/website_sale_comparison/controllers/main.py b/addons/website_sale_comparison/controllers/main.py index 4c4bf5fc923..29b605a9837 100644 --- a/addons/website_sale_comparison/controllers/main.py +++ b/addons/website_sale_comparison/controllers/main.py @@ -23,7 +23,7 @@ class WebsiteSaleProductComparison(WebsiteSale): for var in product.attribute_line_ids.sorted(lambda x: x.attribute_id.sequence): cat_name = var.attribute_id.category_id.name or _('Uncategorized') att_name = var.attribute_id.name - if not product.attribute_value_ids: # create_variant = False + if not var.attribute_id.create_variant: continue res.setdefault(cat_name, OrderedDict()).setdefault(att_name, [' - '] * len(products)) val = product.attribute_value_ids.filtered(lambda x: x.attribute_id == var.attribute_id) From 170c7632f2dca698f01f466ee9d4fb154e71dd81 Mon Sep 17 00:00:00 2001 From: Martin Geubelle Date: Mon, 11 Mar 2019 11:43:53 +0000 Subject: [PATCH 46/57] [FIX] web: hide handle on readonly x2m The widget handle was displayed on x2m fields in form views, even when the field was readonly, which makes no sense. It is now correctly hidden. Fixes #30580 opw-1937833 closes odoo/odoo#31743 Signed-off-by: Aaron Bohy (aab) --- addons/web/static/src/less/form_view.less | 3 ++ .../tests/fields/relational_fields_tests.js | 37 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/addons/web/static/src/less/form_view.less b/addons/web/static/src/less/form_view.less index c0bb80b7a12..b32bca658f0 100644 --- a/addons/web/static/src/less/form_view.less +++ b/addons/web/static/src/less/form_view.less @@ -591,6 +591,9 @@ &.o_form_readonly .o_field_widget .o_list_view .o_row_handle { display: none; // Hide the handler in non-edit mode } + .o_field_widget.o_readonly_modifier .o_list_view .o_row_handle { + display: none; // Hide the handler on readonly fields + } // Attachments .oe_attachment { diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 421cee8a4b1..25b8e08e46d 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -3487,6 +3487,43 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('embedded readonly one2many with handle widget', function (assert) { + assert.expect(4); + + this.data.partner.records[0].turtles = [1, 2, 3]; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + res_id: 1, + }); + + assert.strictEqual(form.$('.o_row_handle').length, 3, + "there should be 3 handles (one for each row)"); + assert.strictEqual(form.$('.o_row_handle:visible').length, 0, + "the handles should be hidden in readonly mode"); + + form.$buttons.find('.o_form_button_edit').click(); + + assert.strictEqual(form.$('.o_row_handle').length, 3, + "the handles should still be there"); + assert.strictEqual(form.$('.o_row_handle:visible').length, 0, + "the handles should still be hidden (on readonly fields)"); + + form.destroy(); + }); + QUnit.test('onchange for embedded one2many in a one2many with a second page', function (assert) { assert.expect(1); From 1c912767d148d22ff68c439dd84fa32df16269eb Mon Sep 17 00:00:00 2001 From: Nicolas Martinelli Date: Tue, 12 Mar 2019 13:48:45 +0000 Subject: [PATCH 47/57] [FIX] stock_account: dropship & AVCO - Activate: 'Order-Specific Routes' and 'Dropshipping' - Create a stockable product in AVCO + AUTO - Purchase the product at $10 and validate the receipt => One product @ $10 in stock - Sell the product via dropshipping - A PO is generated, change unit price to $20, validate - Validate the picking The product in stock has now a cost of $15, i.e. the dropship impacted the cost of the product. Now if the product in stock is sold, the product will go out at a cost of $15. This will lead to a wrong accounting valuation (0 product in stock with -$5 in stock). opw-1922289 opw-1935272 opw-1937672 closes odoo/odoo#31786 Signed-off-by: Nicolas Martinelli (nim) --- addons/stock_account/models/stock.py | 2 +- .../tests/test_stockvaluation.py | 51 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py index 1f337e7de22..6515309e9eb 100644 --- a/addons/stock_account/models/stock.py +++ b/addons/stock_account/models/stock.py @@ -377,7 +377,7 @@ class StockMove(models.Model): tmpl_dict = defaultdict(lambda: 0.0) # adapt standard price on incomming moves if the product cost_method is 'average' std_price_update = {} - for move in self.filtered(lambda move: move.location_id.usage in ('supplier', 'production') and move.product_id.cost_method == 'average'): + for move in self.filtered(lambda move: move._is_in() and move.product_id.cost_method == 'average'): product_tot_qty_available = move.product_id.qty_available + tmpl_dict[move.product_id.id] rounding = move.product_id.uom_id.rounding diff --git a/addons/stock_account/tests/test_stockvaluation.py b/addons/stock_account/tests/test_stockvaluation.py index aa06fabd1e5..50a2096ea24 100644 --- a/addons/stock_account/tests/test_stockvaluation.py +++ b/addons/stock_account/tests/test_stockvaluation.py @@ -2628,6 +2628,57 @@ class TestStockValuation(TransactionCase): self.assertAlmostEqual(self.product1.standard_price, 14.0) + def test_average_perpetual_8(self): + """ Receive 1@10, then dropship 1@20, finally return the dropship. Dropship should not + impact the price. + """ + self.product1.product_tmpl_id.cost_method = 'average' + + move1 = self.env['stock.move'].create({ + 'name': 'IN 1@10', + 'location_id': self.supplier_location.id, + 'location_dest_id': self.stock_location.id, + 'product_id': self.product1.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1, + 'price_unit': 10, + }) + move1._action_confirm() + move1.quantity_done = 1 + move1._action_done() + + self.assertAlmostEqual(self.product1.standard_price, 10) + + move2 = self.env['stock.move'].create({ + 'name': 'IN 1@20', + 'location_id': self.supplier_location.id, + 'location_dest_id': self.customer_location.id, + 'product_id': self.product1.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1, + 'price_unit': 20, + }) + move2._action_confirm() + move2.quantity_done = 1 + move2._action_done() + + self.assertAlmostEqual(self.product1.standard_price, 10.0) + + move3 = self.env['stock.move'].create({ + 'name': 'IN 1@20', + 'location_id': self.customer_location.id, + 'location_dest_id': self.supplier_location.id, + 'product_id': self.product1.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1, + 'price_unit': 20, + }) + move3._action_confirm() + move3.quantity_done = 1 + move3._action_done() + + self.assertAlmostEqual(self.product1.standard_price, 10.0) + def test_average_negative_1(self): """ Test edit in the past. Receive 10, send 20, edit the second move to only send 10. """ From a9a8eeac1de7a62182ad8c32cc745c0ac093520b Mon Sep 17 00:00:00 2001 From: tarteo Date: Thu, 31 Jan 2019 10:17:55 +0000 Subject: [PATCH 48/57] [FIX] account: show missing 'Go to bank statements' button When importing bank statements, a rainbow man is shown but with no action button The active model is account.bank.statement.import in this context Before this commit, the rainbow man was staying with no possible action closes odoo/odoo#30723 Signed-off-by: Martin Trigaux (mat) --- addons/account/static/src/xml/account_reconciliation.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/account/static/src/xml/account_reconciliation.xml b/addons/account/static/src/xml/account_reconciliation.xml index 4e70b99798a..377e27ad1c1 100644 --- a/addons/account/static/src/xml/account_reconciliation.xml +++ b/addons/account/static/src/xml/account_reconciliation.xml @@ -52,7 +52,7 @@

Congrats, you're all done!

You reconciled transactions in .
That's on average seconds per transaction.

-

+

From 0201d896d877c8055070093342f89e4ad776de0d Mon Sep 17 00:00:00 2001 From: Christophe Monniez Date: Wed, 13 Mar 2019 14:35:35 +0000 Subject: [PATCH 49/57] [FIX] packaging: include pot files Since #26134 the po files are compared with pot content when loading a translation. If a term is found multiple times in a po file, the terms that does not exists in the pot files are ignored. If the pot files does not exists, the unicity constraint leads to an error. Closes #31583 closes odoo/odoo#31823 Signed-off-by: Martin Trigaux (mat) --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index 2e0fcc62017..f2264ad41be 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -22,6 +22,7 @@ recursive-include odoo *.otf recursive-include odoo *.pdf recursive-include odoo *.png recursive-include odoo *.po +recursive-include odoo *.pot recursive-include odoo *.rml recursive-include odoo *.rng recursive-include odoo *.rst From d2a6554932ef3aa405306120821b2c08536f1200 Mon Sep 17 00:00:00 2001 From: "goran.kliska" Date: Wed, 13 Mar 2019 15:48:30 +0000 Subject: [PATCH 50/57] [CLA] add Decodio Applications CLA closes odoo/odoo#31824 Signed-off-by: Martin Trigaux (mat) --- doc/cla/corporate/decodio-applications.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 doc/cla/corporate/decodio-applications.md diff --git a/doc/cla/corporate/decodio-applications.md b/doc/cla/corporate/decodio-applications.md new file mode 100644 index 00000000000..a02814c1d97 --- /dev/null +++ b/doc/cla/corporate/decodio-applications.md @@ -0,0 +1,20 @@ +Croatia, 2019-03-05 + +Decodio Applications d.o.o. agrees to the terms of the Odoo Corporate Contributor License +Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this +declaration. + +Signed, + +Goran Kliska gkliska@gmail.com https://github.com/decodio-applications + +List of contributors: + +Goran Kliska goran.kliska@decod.io https://github.com/gkliska +Ivan Vađić ivan.vadjic@decod.io https://github.com/IvanVadjic +Dario Meniss dario.meniss@decod.io https://github.com/dariomeniss +Marko Falatec marko.falatec@decod.io https://github.com/mfalatec +Ana-Maria Olujić ana-maria.olujic@decod.io https://github.com/anamaria2208992 +Davor Bojkić davor.bojkic@decod.io https://github.com/badbole From f5ab04ce5098bb3c3e88ca18ba6b533e6139f8e5 Mon Sep 17 00:00:00 2001 From: Christophe Simonis Date: Wed, 13 Mar 2019 17:55:13 +0100 Subject: [PATCH 51/57] [FIX] web: adapt new test to extra rpc call made on view loading --- addons/web/static/tests/views/form_tests.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js index b37c83c7655..f0f0da04e94 100644 --- a/addons/web/static/tests/views/form_tests.js +++ b/addons/web/static/tests/views/form_tests.js @@ -6893,7 +6893,7 @@ QUnit.module('Views', { }); QUnit.test('Form view from ordered, grouped list view correct context', function (assert) { - assert.expect(9); + assert.expect(10); this.data.partner.records[0].timmy = [12]; var form = createView({ @@ -6919,7 +6919,7 @@ QUnit.module('Views', { }, res_id: 1, mockRPC: function (route, args) { - assert.step(args.method + '_' + args.model); + assert.step(args.model + ":" + args.method); if (args.method === 'read') { assert.ok(args.kwargs.context, 'context is present'); assert.notOk('orderedBy' in args.kwargs.context, @@ -6931,7 +6931,7 @@ QUnit.module('Views', { } }); - assert.verifySteps(['read_partner', 'read_partner_type']); + assert.verifySteps(['partner_type:load_views', 'partner:read', 'partner_type:read']); form.destroy(); }); From 35221a3243c9ae90cfb216a4b0fbbb7409a27712 Mon Sep 17 00:00:00 2001 From: Nicolas Martinelli Date: Thu, 28 Feb 2019 12:34:29 +0000 Subject: [PATCH 52/57] [FIX] product: exclude without attribute values With the demo data: - Go to Sale > Products > Products, search for 'Customizable Desk' - Click on 'Configure Variants' - For variant 'Legs: Steel', remove the attribute values for the 'Conference Chair' - Create a SO, add a line with 'Configure Product' - Select the 'Customizable Desk' with 'Legs: Steel', add The 'Conference Chair' is displayed in the 'Available Options', while it shouldn't. The methods `_get_own_attribute_exclusions` and `_get_parent_attribute_exclusions` compute the exclusions based on the attribute values. Since there is no values in our case, the exclusion is not taken into account. We add a specific case to search for exclusions without values. opw-1945460 --- addons/product/models/product_template.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index 429135b752f..f20e3da3dc5 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -730,6 +730,19 @@ class ProductTemplate(models.Model): self.ensure_one() if not parent_combination: return [] + + # Search for exclusions without attribute value. This means that the template is not + # compatible with the parent combination. If such an exclusion is found, it means that all + # attribute values are excluded. + if parent_combination: + exclusions = self.env['product.template.attribute.exclusion'].search([ + ('product_tmpl_id', '=', self.id), + ('value_ids', '=', False), + ('product_template_attribute_value_id', 'in', parent_combination.ids), + ], limit=1) + if exclusions: + return self.mapped('attribute_line_ids.product_template_value_ids').ids + return [ value_id for filter_line in parent_combination.mapped('exclude_for').filtered( From b5921fbfa8ae7861916e6c86597bdf37c2753101 Mon Sep 17 00:00:00 2001 From: Nicolas Martinelli Date: Wed, 13 Mar 2019 09:38:40 +0000 Subject: [PATCH 53/57] [FIX] sale: exclude all attributes With the demo data: - Go to Sale > Products > Products, search for 'Customizable Desk' - Click on 'Configure Variants' - For variant 'Legs: Steel', add all attribute values for the 'Conference Chair' - Create a SO, add a line with 'Configure Product' - Select the 'Customizable Desk' with 'Legs: Steel', add Boom boom, traceback at template rendering at `first_possible_combination[variant_id_index] == value_id` This is because the `parent_combination` is not taken into account when evaluating `_is_add_to_cart_possible`. closes odoo/odoo#31496 Signed-off-by: Nicolas Martinelli (nim) --- addons/sale/views/sale_product_configurator_templates.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/sale/views/sale_product_configurator_templates.xml b/addons/sale/views/sale_product_configurator_templates.xml index 6711dd2db8b..075a4f11751 100644 --- a/addons/sale/views/sale_product_configurator_templates.xml +++ b/addons/sale/views/sale_product_configurator_templates.xml @@ -152,7 +152,7 @@