From 78bc86cbeccfc5df16218aee2b0d7c501e5c05b5 Mon Sep 17 00:00:00 2001 From: Romain Derie Date: Wed, 16 Jun 2021 11:45:47 +0000 Subject: [PATCH] [IMP] website, *: migrate Universal Analytics to Google Analytics 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *: website_sale, website_sale_delivery The old analytics snippet (isogram) is replaced by the newer gtags snippet. Virtual page views and ecommerce transactions are now tracked using gtags. The e-commerce data format is updated to correspond with the new GA4 format. task-2500769 closes odoo/odoo#72233 Signed-off-by: Romain Derie (rde) Co-authored-by: Romain Derie Co-authored-by: Tom De Caluwé --- .../static/src/xml/website.backend.xml | 6 +- .../src/xml/website.res_config_settings.xml | 6 +- .../views/res_config_settings_views.xml | 6 +- addons/website/views/website_templates.xml | 18 ++-- addons/website_sale/controllers/main.py | 36 +++---- addons/website_sale/controllers/variant.py | 19 ++-- .../website_sale/models/product_template.py | 11 +++ .../static/src/js/variant_mixin.js | 11 +++ .../static/src/js/website_sale.js | 23 ++++- .../static/src/js/website_sale_tracking.js | 65 +++++++------ .../tours/website_sale_google_analytics.js | 96 +++++++++++++++++++ .../tests/tours/website_sale_mock_tracking.js | 42 ++++++++ .../website_sale/tests/test_sale_process.py | 5 + addons/website_sale/views/templates.xml | 6 +- .../static/src/js/website_sale_comparison.js | 8 +- .../website_sale_comparison_template.xml | 3 +- .../website_sale_delivery/controllers/main.py | 6 +- .../static/src/js/website_sale_options.js | 22 +++++ .../static/src/js/website_sale_wishlist.js | 10 +- .../views/website_sale_wishlist_template.xml | 5 +- 20 files changed, 310 insertions(+), 94 deletions(-) create mode 100644 addons/website_sale/static/tests/tours/website_sale_google_analytics.js create mode 100644 addons/website_sale/static/tests/tours/website_sale_mock_tracking.js diff --git a/addons/website/static/src/xml/website.backend.xml b/addons/website/static/src/xml/website.backend.xml index 943a948ef87..fdb25242cbb 100644 --- a/addons/website/static/src/xml/website.backend.xml +++ b/addons/website/static/src/xml/website.backend.xml @@ -61,10 +61,10 @@
- Your Tracking ID: - + Your Measurement ID: + - How to get my Tracking ID + How to get my Measurement ID

Your Client ID: diff --git a/addons/website/static/src/xml/website.res_config_settings.xml b/addons/website/static/src/xml/website.res_config_settings.xml index 6b6d48172bd..eaa1a682e51 100644 --- a/addons/website/static/src/xml/website.res_config_settings.xml +++ b/addons/website/static/src/xml/website.res_config_settings.xml @@ -9,14 +9,10 @@ consent in your country.

- For session cookies, authentification and analytics*, + For session cookies, authentification and analytics, you do not need to ask for the consent (see e.g. Opinion 04/2012 on Cookie Consent Exemption by the EU Art.29 WP).

-

- * provided that your analytics is anonymized, which is not the - case by default with Google Analytics. -

diff --git a/addons/website/views/res_config_settings_views.xml b/addons/website/views/res_config_settings_views.xml index 56598db0ef4..282a5efca8b 100644 --- a/addons/website/views/res_config_settings_views.xml +++ b/addons/website/views/res_config_settings_views.xml @@ -284,8 +284,8 @@
-
@@ -293,7 +293,7 @@ - How to get my Tracking ID + How to get my Measurement ID diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml index c0cb8b05980..1c5314c7d25 100644 --- a/addons/website/views/website_templates.xml +++ b/addons/website/views/website_templates.xml @@ -146,15 +146,15 @@ - + + + + diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index 8258bb80fc1..d58b247d9a9 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -1095,7 +1095,10 @@ class WebsiteSale(http.Controller): sale_order_id = request.session.get('sale_last_order_id') if sale_order_id: order = request.env['sale.order'].sudo().browse(sale_order_id) - return request.render("website_sale.confirmation", {'order': order}) + return request.render("website_sale.confirmation", { + 'order': order, + 'order_tracking_info': self.order_2_return_dict(order), + }) else: return request.redirect('/shop') @@ -1109,16 +1112,6 @@ class WebsiteSale(http.Controller): else: return request.redirect('/shop') - @http.route(['/shop/tracking_last_order'], type='json', auth="public") - def tracking_cart(self, **post): - """ return data about order in JSON needed for google analytics""" - ret = {} - sale_order_id = request.session.get('sale_last_order_id') - if sale_order_id: - order = request.env['sale.order'].sudo().browse(sale_order_id) - ret = self.order_2_return_dict(order) - return ret - # ------------------------------------------------------ # Edit # ------------------------------------------------------ @@ -1163,10 +1156,9 @@ class WebsiteSale(http.Controller): for line in order_lines: product = line.product_id ret.append({ - 'id': line.order_id.id, - 'sku': product.barcode or product.id, - 'name': product.name or '-', - 'category': product.categ_id.name or '-', + 'item_id': product.barcode or product.id, + 'item_name': product.name or '-', + 'item_category': product.categ_id.name or '-', 'price': line.price_unit, 'quantity': line.product_uom_qty, }) @@ -1175,14 +1167,12 @@ class WebsiteSale(http.Controller): def order_2_return_dict(self, order): """ Returns the tracking_cart dict of the order for Google analytics basically defined to be inherited """ return { - 'transaction': { - 'id': order.id, - 'affiliation': order.company_id.name, - 'revenue': order.amount_total, - 'tax': order.amount_tax, - 'currency': order.currency_id.name - }, - 'lines': self.order_lines_2_google_api(order.order_line) + 'transaction_id': order.id, + 'affiliation': order.company_id.name, + 'value': order.amount_total, + 'tax': order.amount_tax, + 'currency': order.currency_id.name, + 'items': self.order_lines_2_google_api(order.order_line), } @http.route(['/shop/country_infos/'], type='json', auth="public", methods=['POST'], website=True) diff --git a/addons/website_sale/controllers/variant.py b/addons/website_sale/controllers/variant.py index 158d2cf97e2..3618a22e1d1 100644 --- a/addons/website_sale/controllers/variant.py +++ b/addons/website_sale/controllers/variant.py @@ -5,6 +5,7 @@ from odoo.http import request from odoo.addons.sale.controllers.variant import VariantController + class WebsiteSaleVariantController(VariantController): @http.route(['/sale/get_combination_info_website'], type='json', auth="public", methods=['POST'], website=True) def get_combination_info_website(self, product_template_id, product_id, combination, add_qty, **kw): @@ -12,15 +13,17 @@ class WebsiteSaleVariantController(VariantController): This route is called in JS by appending _website to the base route. """ kw.pop('pricelist_id') - res = self.get_combination_info(product_template_id, product_id, combination, add_qty, request.website.get_current_pricelist(), **kw) + combination = self.get_combination_info(product_template_id, product_id, combination, add_qty, request.website.get_current_pricelist(), **kw) - carousel_view = request.env['ir.ui.view']._render_template('website_sale.shop_product_carousel', - values={ - 'product': request.env['product.template'].browse(res['product_template_id']), - 'product_variant': request.env['product.product'].browse(res['product_id']), - }) - res['carousel'] = carousel_view - return res + if request.website.google_analytics_key: + combination['product_tracking_info'] = request.env['product.template'].get_google_analytics_data(combination) + + carousel_view = request.env['ir.ui.view']._render_template('website_sale.shop_product_carousel', values={ + 'product': request.env['product.template'].browse(combination['product_template_id']), + 'product_variant': request.env['product.product'].browse(combination['product_id']), + }) + combination['carousel'] = carousel_view + return combination @http.route(auth="public") def create_product_variant(self, product_template_id, product_template_attribute_value_ids, **kwargs): diff --git a/addons/website_sale/models/product_template.py b/addons/website_sale/models/product_template.py index 156105ce6a2..054a733668a 100644 --- a/addons/website_sale/models/product_template.py +++ b/addons/website_sale/models/product_template.py @@ -405,3 +405,14 @@ class ProductTemplate(models.Model): slugs = [slug(category) for category in product.public_categ_ids] data['category_url'] = '/shop/category/%s' % ','.join(slugs) return results_data + + @api.model + def get_google_analytics_data(self, combination): + product = self.env['product.product'].browse(combination['product_id']) + return { + 'item_id': product.barcode or product.id, + 'item_name': combination['display_name'], + 'item_category': product.categ_id.name or '-', + 'currency': product.currency_id.name, + 'price': combination['list_price'], + } diff --git a/addons/website_sale/static/src/js/variant_mixin.js b/addons/website_sale/static/src/js/variant_mixin.js index ab45708f2c2..7fa78bb7b4c 100644 --- a/addons/website_sale/static/src/js/variant_mixin.js +++ b/addons/website_sale/static/src/js/variant_mixin.js @@ -29,6 +29,17 @@ VariantMixin._onChangeCombination = function (ev, $parent, combination) { $pricePerUom.parents(".o_base_unit_price_wrapper").addClass("d-none"); } } + + // Triggers a new JS event with the correct payload, which is then handled + // by the google analytics tracking code. + // Indeed, every time another variant is selected, a new view_item event + // needs to be tracked by google analytics. + if ('product_tracking_info' in combination) { + const $product = $('#product_detail'); + $product.data('product-tracking-info', combination['product_tracking_info']); + $product.trigger('view_item_event', combination['product_tracking_info']); + } + originalOnChangeCombination.apply(this, [ev, $parent, combination]); }; diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js index da1e435f41d..0c0e62e2dfc 100644 --- a/addons/website_sale/static/src/js/website_sale.js +++ b/addons/website_sale/static/src/js/website_sale.js @@ -148,14 +148,13 @@ odoo.define('website_sale.website_sale', function (require) { var core = require('web.core'); var config = require('web.config'); var publicWidget = require('web.public.widget'); -var VariantMixin = require('sale.VariantMixin'); +var VariantMixin = require('website_sale.VariantMixin'); var wSaleUtils = require('website_sale.utils'); const cartHandlerMixin = wSaleUtils.cartHandlerMixin; require("web.zoomodoo"); const {extraMenuUpdateCallbacks} = require('website.content.menu'); const dom = require('web.dom'); - publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(VariantMixin, cartHandlerMixin, { selector: '.oe_website_sale', events: _.extend({}, VariantMixin.events || {}, { @@ -533,9 +532,16 @@ publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(VariantMixin, car * @returns {Promise} */ _submitForm: function () { - let params = this.rootProduct; - params.add_qty = params.quantity; + const params = this.rootProduct; + const $product = $('#product_detail'); + const productTrackingInfo = $product.data('product-tracking-info'); + if (productTrackingInfo) { + productTrackingInfo.quantity = params.quantity; + $product.trigger('add_to_cart_event', [productTrackingInfo]); + } + + params.add_qty = params.quantity; params.product_custom_attribute_values = JSON.stringify(params.product_custom_attribute_values); params.no_variant_attribute_values = JSON.stringify(params.no_variant_attribute_values); return this.addToCart(params); @@ -961,6 +967,15 @@ publicWidget.registry.websiteSaleProductPageReviews = publicWidget.Widget.extend this.$target.find('.o_portal_chatter_composer').css('top', dom.scrollFixedOffset() + 20); }, }); + +return { + WebsiteSale: publicWidget.registry.WebsiteSale, + WebsiteSaleLayout: publicWidget.registry.WebsiteSaleLayout, + websiteSaleCart: publicWidget.registry.websiteSaleCart, + WebsiteSaleCarouselProduct: publicWidget.registry.websiteSaleCarouselProduct, + WebsiteSaleProductPageReviews: publicWidget.registry.websiteSaleProductPageReviews, +}; + }); odoo.define('website_sale.price_range_option', function (require) { diff --git a/addons/website_sale/static/src/js/website_sale_tracking.js b/addons/website_sale/static/src/js/website_sale_tracking.js index 92850707755..1fcd852db54 100644 --- a/addons/website_sale/static/src/js/website_sale_tracking.js +++ b/addons/website_sale/static/src/js/website_sale_tracking.js @@ -10,6 +10,8 @@ publicWidget.registry.websiteSaleTracking = publicWidget.Widget.extend({ 'click div.oe_cart a[href^="/web?redirect"][href$="/shop/checkout"]': '_onCustomerSignin', 'click form[action="/shop/confirm_order"] a.a-submit': '_onOrder', 'click form[target="_self"] button[type=submit]': '_onOrderPayment', + 'view_item_event': '_onViewItem', + 'add_to_cart_event': '_onAddToCart', }, /** @@ -18,32 +20,13 @@ publicWidget.registry.websiteSaleTracking = publicWidget.Widget.extend({ start: function () { var self = this; - // Watching a product - if (this.$el.is('#product_detail')) { - var productID = this.$('input[name="product_id"]').attr('value'); - this._vpv('/stats/ecom/product_view/' + productID); - } - // ... - if (this.$('div.oe_website_sale_tx_status').length) { - this._trackGA('require', 'ecommerce'); - - var orderID = this.$('div.oe_website_sale_tx_status').data('order-id'); + const $confirmation = this.$('div.oe_website_sale_tx_status'); + if ($confirmation.length) { + const orderID = $confirmation.data('order-id'); + const json = $confirmation.data('order-tracking-info'); this._vpv('/stats/ecom/order_confirmed/' + orderID); - - this._rpc({ - route: '/shop/tracking_last_order/', - }).then(function (o) { - self._trackGA('ecommerce:clear'); - - if (o.transaction && o.lines) { - self._trackGA('ecommerce:addTransaction', o.transaction); - _.forEach(o.lines, function (line) { - self._trackGA('ecommerce:addItem', line); - }); - } - self._trackGA('ecommerce:send'); - }); + self._trackGA('event', 'purchase', json); } return this._super.apply(this, arguments); @@ -57,16 +40,15 @@ publicWidget.registry.websiteSaleTracking = publicWidget.Widget.extend({ * @private */ _trackGA: function () { - var websiteGA = window.ga || function () {}; + const websiteGA = window.gtag || function () {}; websiteGA.apply(this, arguments); }, /** * @private */ _vpv: function (page) { //virtual page view - this._trackGA('send', 'pageview', { - 'page': page, - 'title': document.title, + this._trackGA('event', 'page_view', { + 'page_path': page, }); }, @@ -74,6 +56,30 @@ publicWidget.registry.websiteSaleTracking = publicWidget.Widget.extend({ // Handlers //-------------------------------------------------------------------------- + /** + * @private + */ + _onViewItem(event, productTrackingInfo) { + const trackingInfo = { + 'currency': productTrackingInfo['currency'], + 'value': productTrackingInfo['price'], + 'items': [productTrackingInfo], + }; + this._trackGA('event', 'view_item', trackingInfo); + }, + + /** + * @private + */ + _onAddToCart(event, ...productsTrackingInfo) { + const trackingInfo = { + 'currency': productsTrackingInfo[0]['currency'], + 'value': productsTrackingInfo.reduce((acc, val) => acc + val['price'] * val['quantity'], 0), + 'items': productsTrackingInfo, + }; + this._trackGA('event', 'add_to_cart', trackingInfo); + }, + /** * @private */ @@ -110,4 +116,7 @@ publicWidget.registry.websiteSaleTracking = publicWidget.Widget.extend({ this._vpv('/stats/ecom/order_payment/' + method); }, }); + +return publicWidget.registry.websiteSaleTracking; + }); diff --git a/addons/website_sale/static/tests/tours/website_sale_google_analytics.js b/addons/website_sale/static/tests/tours/website_sale_google_analytics.js new file mode 100644 index 00000000000..3a3cf295c62 --- /dev/null +++ b/addons/website_sale/static/tests/tours/website_sale_google_analytics.js @@ -0,0 +1,96 @@ +odoo.define('website_sale.google_analytics', function (require) { +'use strict'; + +const tour = require("web_tour.tour"); +const websiteSaleTracking = require('website_sale.tracking'); +const WebsiteSale = require('website_sale.website_sale').WebsiteSale; + +let itemId; + +tour.register('google_analytics_view_item', { + test: true, + url: '/shop?search=Customizable Desk', +}, +[ + { + content: "select customizable desk", + trigger: '.oe_product_cart a:contains("Customizable Desk")', + }, + { + content: "check view_item events", + trigger: '#product_detail', + run: () => { + // If we don't explicitly wait for the getCombinationInfo ajax + // call, the tour fails when running in phantomjs. The actual + // test is executed in a separate tour stage, triggered when + // the ajax call is ready. + WebsiteSale.getCombinationInfoPromise().then(() => { + $('body').addClass('combination_info_ready'); + }); + } + }, + { + trigger: 'body.combination_info_ready', + run: () => { + const events = websiteSaleTracking.getEvents('view_item'); + $('body').removeClass('combination_info_ready'); + if (events.length !== 1) { + console.error('No view item was generated'); + } else { + itemId = events[0]['item_id']; + } + } + }, + { + content: 'select another variant', + trigger: 'ul.js_add_cart_variants ul.list-inline li:has(label.active) + li:has(label) input', + }, + { + content: "check view_item events", + trigger: '#product_detail', + run: () => { + WebsiteSale.getCombinationInfoPromise().then(() => { + $('body').addClass('combination_info_ready'); + }); + } + }, + { + trigger: 'body.combination_info_ready', + run: () => { + const events = websiteSaleTracking.getEvents('view_item'); + $('body').removeClass('combination_info_ready'); + if (events.length !== 2) { + console.error('No second view event was generated'); + } else if (itemId === events[1]['item_id']) { + console.error('The second variant has the same id as the first one'); + } + } + }, +]); + +tour.register('google_analytics_add_to_cart', { + test: true, + url: '/shop?search=Acoustic Bloc Screens', +}, +[ + { + content: "select Acoustic Bloc Screens", + trigger: '.oe_product_cart a:contains("Acoustic Bloc Screens")', + }, + { + content: "click add to cart button on product page", + trigger: '#add_to_cart', + }, + { + content: 'check add to cart event', + trigger: 'a:has(.my_cart_quantity:containsExact(1))', + run: () => { + const events = websiteSaleTracking.getEvents('add_to_cart'); + if (events.length !== 1) { + console.error('No add to cart event was generated'); + } + }, + }, +]); + +}); diff --git a/addons/website_sale/static/tests/tours/website_sale_mock_tracking.js b/addons/website_sale/static/tests/tours/website_sale_mock_tracking.js new file mode 100644 index 00000000000..ec74369d68a --- /dev/null +++ b/addons/website_sale/static/tests/tours/website_sale_mock_tracking.js @@ -0,0 +1,42 @@ +odoo.define('website_sale.tour_mock_tracking', function (require) { + +const publicWidget = require('web.public.widget'); +const websiteSaleTracking = require('website_sale.tracking'); + +require('website_sale.website_sale'); + +const events = { + view_item: [], + add_to_cart: [], + remove_from_cart: [], +}; + +let promise; + +publicWidget.registry.WebsiteSale.include({ + _getCombinationInfo() { + promise = this._super(...arguments); + return promise; + }, +}); + +publicWidget.registry.WebsiteSale.getCombinationInfoPromise = function () { + let result = promise; + promise = undefined; + return result; +}; + +websiteSaleTracking.include({ + _onViewItem(event, data) { + events.view_item.push(data); + }, + _onAddToCart(event, data) { + events.add_to_cart.push(data); + }, +}); + +websiteSaleTracking.getEvents = function (eventName) { + return events[eventName]; +}; + +}); diff --git a/addons/website_sale/tests/test_sale_process.py b/addons/website_sale/tests/test_sale_process.py index 5713b6a9820..3f39dba807c 100644 --- a/addons/website_sale/tests/test_sale_process.py +++ b/addons/website_sale/tests/test_sale_process.py @@ -99,6 +99,11 @@ class TestUi(HttpCaseWithUserDemo): self.start_tour("/", 'website_sale_tour') + def test_05_google_analytics_tracking(self): + self.env['website'].browse(1).write({'google_analytics_key': 'G-XXXXXXXXXXX'}) + self.start_tour("/shop", 'google_analytics_view_item') + self.start_tour("/shop", 'google_analytics_add_to_cart') + @odoo.tests.tagged('post_install', '-at_install') class TestWebsiteSaleCheckoutAddress(TransactionCaseWithUserDemo): diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 39efcf54407..725143a625d 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -568,7 +568,9 @@
-
+