diff --git a/addons/website_sale/models/res_config_settings.py b/addons/website_sale/models/res_config_settings.py index d36fcb32e75..2f0c38ae310 100644 --- a/addons/website_sale/models/res_config_settings.py +++ b/addons/website_sale/models/res_config_settings.py @@ -27,6 +27,8 @@ class ResConfigSettings(models.TransientModel): module_website_sale_digital = fields.Boolean("Digital Content") module_website_sale_wishlist = fields.Boolean("Wishlists") module_website_sale_comparison = fields.Boolean("Product Comparison Tool") + module_website_sale_autocomplete = fields.Boolean('Address Autocomplete') + module_account = fields.Boolean("Invoicing") module_website_sale_picking = fields.Boolean('On Site Payments & Picking') diff --git a/addons/website_sale/views/res_config_settings_views.xml b/addons/website_sale/views/res_config_settings_views.xml index 8674ebea9f2..41b626ca0ef 100644 --- a/addons/website_sale/views/res_config_settings_views.xml +++ b/addons/website_sale/views/res_config_settings_views.xml @@ -393,6 +393,20 @@ + + +
+
+ +
+
+
+
+
diff --git a/addons/website_sale_autocomplete/__init__.py b/addons/website_sale_autocomplete/__init__.py new file mode 100644 index 00000000000..c48d23bbb1d --- /dev/null +++ b/addons/website_sale_autocomplete/__init__.py @@ -0,0 +1,5 @@ +# -*- encoding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controllers +from . import models diff --git a/addons/website_sale_autocomplete/__manifest__.py b/addons/website_sale_autocomplete/__manifest__.py new file mode 100644 index 00000000000..fdf030b657f --- /dev/null +++ b/addons/website_sale_autocomplete/__manifest__.py @@ -0,0 +1,30 @@ +# -*- encoding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Google places autocompletion', + 'category': 'Website/Website', + 'summary': 'Assist your users with automatic completion & suggestions when filling their address during checkout', + 'version': '1.0', + 'description': "Assist your users with automatic completion & suggestions when filling their address during checkout", + 'depends': [ + 'website_sale' + ], + 'data': [ + 'views/templates.xml', + 'views/res_config_settings_views.xml', + ], + 'assets': { + 'web.assets_frontend': [ + 'website_sale_autocomplete/static/src/js/address_form.js' + ], + 'web.assets_tests': [ + 'website_sale_autocomplete/static/tests/**/*.js' + ], + 'web.assets_qweb': [ + 'website_sale_autocomplete/static/src/xml/autocomplete.xml', + ], + }, + 'installable': True, + 'license': 'LGPL-3', +} diff --git a/addons/website_sale_autocomplete/controllers/__init__.py b/addons/website_sale_autocomplete/controllers/__init__.py new file mode 100644 index 00000000000..e425c18c14b --- /dev/null +++ b/addons/website_sale_autocomplete/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- encoding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import main diff --git a/addons/website_sale_autocomplete/controllers/main.py b/addons/website_sale_autocomplete/controllers/main.py new file mode 100644 index 00000000000..c45873ba53c --- /dev/null +++ b/addons/website_sale_autocomplete/controllers/main.py @@ -0,0 +1,153 @@ +# -*- encoding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import requests + +from odoo import http +from odoo.http import request +from odoo.tools import html2plaintext + +FIELDS_MAPPING = { + 'country': ['country'], + 'street_number': ['number'], + 'locality': ['city'], # If locality exists, use it instead of the more general administrative area + 'route': ['street'], + 'postal_code': ['zip'], + 'administrative_area_level_1': ['state', 'city'], + 'administrative_area_level_2': ['state', 'country'] +} + +# If a google fields may correspond to multiple standard fields, the first occurrence in the list will overwrite following entries. +FIELDS_PRIORITY = ['country', 'street_number', 'neighborhood', 'locality', 'route', 'postal_code', + 'administrative_area_level_1', 'administrative_area_level_2'] +GOOGLE_PLACES_ENDPOINT = 'https://maps.googleapis.com/maps/api/place' +TIMEOUT = 2.5 + + +class AutoCompleteController(http.Controller): + + def _translate_google_to_standard(self, google_fields): + standard_data = {} + + for google_field in google_fields: + fields_standard = FIELDS_MAPPING[google_field['type']] if google_field['type'] in FIELDS_MAPPING else [] + + for field_standard in fields_standard: + if field_standard in standard_data: # if a value is already assigned, do not overwrite it. + continue + # Convert state and countries to odoo ids + if field_standard == 'country': + standard_data[field_standard] = request.env['res.country'].search( + [('code', '=', google_field['short_name'].upper())])[0].id + elif field_standard == 'state': + state = request.env['res.country.state'].search( + [('code', '=', google_field['short_name'].upper()), + ('country_id.id', '=', standard_data['country'])]) + if len(state) == 1: + standard_data[field_standard] = state.id + else: + standard_data[field_standard] = google_field['long_name'] + return standard_data + + def _guess_number_from_input(self, source_input, standard_address): + """ + Google might not send the house number in case the address + does not exist in their database. + We try to guess the number from the user's input to avoid losing the info. + """ + # Remove other parts from address to make better guesses + guessed_house_number = source_input \ + .replace(standard_address.get('zip', ''), '') \ + .replace(standard_address.get('street', ''), '') \ + .replace(standard_address.get('city', ''), '') + guessed_house_number = guessed_house_number.split(',')[0].strip() + return guessed_house_number + + def _perform_place_search(self, partial_address, api_key=None, session_id=None, language_code=None, country_code=None): + params = { + 'key': api_key, + 'fields': 'formatted_address,name', + 'inputtype': 'textquery', + 'types': 'address', + 'input': partial_address + } + if country_code: + params['components'] = f'country:{country_code}' + if language_code: + params['language'] = language_code + if session_id: + params['sessiontoken'] = session_id + + try: + results = requests.get(f'{GOOGLE_PLACES_ENDPOINT}/autocomplete/json', params=params, timeout=TIMEOUT).json() + except (TimeoutError, ValueError): + return { + 'results': [], + 'session_id': session_id + } + results = results.get('predictions', []) + + # Convert google specific format to standard format. + return { + 'results': [{ + 'formatted_address': result['description'], + 'google_place_id': result['place_id'], + } for result in results], + 'session_id': session_id + } + + def _perform_complete_place_search(self, address, api_key=None, google_place_id=None, language_code=None, session_id=None): + params = { + 'key': api_key, + 'place_id': google_place_id, + 'fields': 'address_component,adr_address' + } + + if language_code: + params['language'] = language_code + if session_id: + params['sessiontoken'] = session_id + + try: + results = requests.get(f'{GOOGLE_PLACES_ENDPOINT}/details/json', params=params, timeout=TIMEOUT).json() + except (TimeoutError, ValueError): + return {'address': None} + + try: + html_address = results['result']['adr_address'] + results = results['result']['address_components'] # Get rid of useless extra data + except KeyError: + return {'address': None} + + # Keep only the first type from the list of types + for res in results: + res['type'] = res.pop('types')[0] + + # Sort the result by their priority. + results.sort(key=lambda r: FIELDS_PRIORITY.index(r['type']) if r['type'] in FIELDS_PRIORITY else 100) + + standard_address = self._translate_google_to_standard(results) + + if 'number' not in standard_address: + standard_address['number'] = self._guess_number_from_input(address, standard_address) + standard_address['formatted_street_number'] = f'{standard_address["number"]} {standard_address.get("street", "")}' + else: + formatted_from_html = html2plaintext(html_address.split(',')[0]) + formatted_manually = f'{standard_address["number"]} {standard_address.get("street", "")}' + # Sometimes, the google api sends back abbreviated data : + # "52 High Road Street" becomes "52 HR St" for example. We usually take the result from google, but if it's an abbreviation, take our guess instead. + if len(formatted_from_html) >= len(formatted_manually): + standard_address['formatted_street_number'] = formatted_from_html + else: + standard_address['formatted_street_number'] = formatted_manually + return standard_address + + @http.route('/autocomplete/address', methods=['POST'], type='json', auth='public', website=True) + def _autocomplete_address(self, partial_address, session_id=None): + api_key = request.env['website'].get_current_website().sudo().google_places_api_key + return self._perform_place_search(partial_address, session_id=session_id, api_key=api_key) + + @http.route('/autocomplete/address_full', methods=['POST'], type='json', auth='public', website=True) + def _autocomplete_address_full(self, address, session_id=None, google_place_id=None, **kwargs): + api_key = request.env['website'].get_current_website().sudo().google_places_api_key + return self._perform_complete_place_search(address, google_place_id=google_place_id, + session_id=session_id, api_key=api_key, **kwargs) diff --git a/addons/website_sale_autocomplete/models/__init__.py b/addons/website_sale_autocomplete/models/__init__.py new file mode 100644 index 00000000000..1626b097a20 --- /dev/null +++ b/addons/website_sale_autocomplete/models/__init__.py @@ -0,0 +1,5 @@ +# -*- encoding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import website +from . import res_config_settings diff --git a/addons/website_sale_autocomplete/models/res_config_settings.py b/addons/website_sale_autocomplete/models/res_config_settings.py new file mode 100644 index 00000000000..13eea8d76de --- /dev/null +++ b/addons/website_sale_autocomplete/models/res_config_settings.py @@ -0,0 +1,10 @@ +from odoo import models, fields + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + google_places_api_key = fields.Char( + string='Google Places API Key', + related='website_id.google_places_api_key', + readonly=False) diff --git a/addons/website_sale_autocomplete/models/website.py b/addons/website_sale_autocomplete/models/website.py new file mode 100644 index 00000000000..df33f20da68 --- /dev/null +++ b/addons/website_sale_autocomplete/models/website.py @@ -0,0 +1,11 @@ +# -*- encoding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import models, fields + + +class Website(models.Model): + _inherit = 'website' + + google_places_api_key = fields.Char( + string='Google Places API Key', + groups="base.group_system") diff --git a/addons/website_sale_autocomplete/static/src/img/powered_by_google_on_non_white.png b/addons/website_sale_autocomplete/static/src/img/powered_by_google_on_non_white.png new file mode 100644 index 00000000000..abe09138893 Binary files /dev/null and b/addons/website_sale_autocomplete/static/src/img/powered_by_google_on_non_white.png differ diff --git a/addons/website_sale_autocomplete/static/src/img/powered_by_google_on_white.png b/addons/website_sale_autocomplete/static/src/img/powered_by_google_on_white.png new file mode 100644 index 00000000000..43a15352747 Binary files /dev/null and b/addons/website_sale_autocomplete/static/src/img/powered_by_google_on_white.png differ diff --git a/addons/website_sale_autocomplete/static/src/js/address_form.js b/addons/website_sale_autocomplete/static/src/js/address_form.js new file mode 100644 index 00000000000..7f7e787ed84 --- /dev/null +++ b/addons/website_sale_autocomplete/static/src/js/address_form.js @@ -0,0 +1,108 @@ +/** @odoo-module */ + +import publicWidget from 'web.public.widget'; +import { DropPrevious } from 'web.concurrency'; +import { debounce } from "@web/core/utils/timing"; +import { qweb as QWeb } from 'web.core'; + +publicWidget.registry.AddressForm = publicWidget.Widget.extend({ + selector: '.oe_cart .checkout_autoformat:has(input[name="street"][data-autocomplete-enabled="1"])', + events: { + 'input input[name="street"]': '_onChangeStreet', + 'click .js_autocomplete_result': '_onClickAutocompleteResult' + }, + xmlDependencies: ['/website_sale_autocomplete/static/src/xml/autocomplete.xml'], + + init: function() { + this.streetAndNumberInput = document.querySelector('input[name="street"]'); + this.cityInput = document.querySelector('input[name="city"]'); + this.zipInput = document.querySelector('input[name="zip"]'); + this.countrySelect = document.querySelector('select[name="country_id"]'); + this.stateSelect = document.querySelector('select[name="state_id"]'); + this.dp = new DropPrevious(); + this.sessionId = this._generateUUID() + + this._onChangeStreet = debounce(this._onChangeStreet, 200); + this._super.apply(this, arguments); + }, + + /** + * Used to generate a unique session ID for the places API. + * + * @private + */ + _generateUUID: function() { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0, v = c == "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + }, + + _onChangeStreet: async function(ev) { + this.dp.add( + this._rpc({ + route: '/autocomplete/address', + params: { + partial_address: ev.currentTarget.value, + session_id: this.sessionId || null + } + })).then((response) => { + if (response.results) { + const inputContainer = ev.currentTarget.parentNode; + const dropdown = inputContainer.querySelector('.dropdown-menu'); + if (dropdown) { + dropdown.remove(); + } + inputContainer.appendChild($(QWeb.render("website_sale_autocomplete.AutocompleteDropDown", { + results: response.results + }))[0]); + } + if (response.session_id) { + this.sessionId = response.session_id; + } + } + ); + }, + + _onClickAutocompleteResult: async function(ev) { + const dropDown = ev.currentTarget.parentNode; + + const spinner = document.createElement('div'); + dropDown.innerText = ''; + dropDown.classList.add('d-flex', 'justify-content-center', 'align-items-center'); + spinner.classList.add('spinner-border', 'text-warning', 'text-center', 'm-auto'); + dropDown.appendChild(spinner); + + const address = await this._rpc({ + route: '/autocomplete/address_full', + params: { + address: ev.currentTarget.innerText, + google_place_id: ev.currentTarget.dataset.googlePlaceId, + session_id: this.sessionId || null + } + }); + if (address.formatted_street_number) { + this.streetAndNumberInput.value = address.formatted_street_number; + } + // Text fields, empty if no value in order to avoid the user missing old data. + this.zipInput.value = address.zip || ''; + this.cityInput.value = address.city || ''; + + // Selects based on odoo ids + if (address.country) { + this.countrySelect.value = address.country; + // Let the state select know that the country has changed so that it may fetch the correct states or disappear. + this.countrySelect.dispatchEvent(new Event('change', {bubbles: true})); + } + if (address.state) { + // Waits for the stateSelect to update before setting the state. + new MutationObserver((entries, observer) => { + this.stateSelect.value = address.state; + observer.disconnect(); + }).observe(this.stateSelect, { + childList: true, // Trigger only if the options change + }); + } + dropDown.remove(); + }, +}); diff --git a/addons/website_sale_autocomplete/static/src/xml/autocomplete.xml b/addons/website_sale_autocomplete/static/src/xml/autocomplete.xml new file mode 100644 index 00000000000..a16c42ffd52 --- /dev/null +++ b/addons/website_sale_autocomplete/static/src/xml/autocomplete.xml @@ -0,0 +1,15 @@ + + + + +
+ + + + Powered by Google +
+
+ +
diff --git a/addons/website_sale_autocomplete/static/tests/autocomplete_tour.js b/addons/website_sale_autocomplete/static/tests/autocomplete_tour.js new file mode 100644 index 00000000000..a749cca4798 --- /dev/null +++ b/addons/website_sale_autocomplete/static/tests/autocomplete_tour.js @@ -0,0 +1,71 @@ +/** @odoo-module */ + +import tour from 'web_tour.tour'; +import tourUtils from 'website_sale.tour_utils'; + + +function fail (errorMessage) { + tour._consume_tour(tour.running_tour, errorMessage); +} + +tour.register('autocomplete_tour', { + test: true, + url: '/shop', // /shop/address is redirected if no sales order +}, [{ + content: "search test product", + trigger: 'form input[name="search"]', + run: "text A test product", +},{ + content: 'Go to the product page', + trigger: '.dropdown-item:contains("A test product")' +}, { + content: 'Add to cart', + trigger: '#add_to_cart' +}, + tourUtils.goToCart(1), +{ + content: 'Go to process checkout', + trigger: 'a:contains("Process Checkout")' +}, { // Actual test + content: 'Input in Street & Number field', + trigger: 'input[name="street"]', + run: 'text This is a test' +}, { + content: 'Check if results have appeared', + trigger: '.js_autocomplete_result', + run: function () {} +}, { + content: 'Input again in street field', + trigger: 'input[name="street"]', + run: 'text add more' +}, { + content: 'Click on the first result', + trigger: '.js_autocomplete_result' +}, { + content: 'Verify the autocomplete box disappeared', + trigger: 'body:not(:has(.js_autocomplete_result))' +}, { // Verify test data has been input + content: 'Check Street & number have been set', + trigger: 'input[name="street"]', + run: function () { + if (this.$anchor.val() !== '42 A fictional Street') { + fail('Street value is not correct : ' + this.$anchor.val()) + } + } +}, { + content: 'Check City is not empty anymore', + trigger: 'input[name="city"]', + run: function () { + if (this.$anchor.val() !== 'A Fictional City') { + fail('Street value is not correct : ' + this.$anchor.val()) + } + } +}, { + content: 'Check Zip code is not empty anymore', + trigger: 'input[name="zip"]', + run: function () { + if (this.$anchor.val() !== '12345') { + fail('Street value is not correct : ' + this.$anchor.val()) + } + } +}]); diff --git a/addons/website_sale_autocomplete/tests/__init__.py b/addons/website_sale_autocomplete/tests/__init__.py new file mode 100644 index 00000000000..e732297c6a6 --- /dev/null +++ b/addons/website_sale_autocomplete/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- encoding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_ui diff --git a/addons/website_sale_autocomplete/tests/test_ui.py b/addons/website_sale_autocomplete/tests/test_ui.py new file mode 100644 index 00000000000..08775d42dc0 --- /dev/null +++ b/addons/website_sale_autocomplete/tests/test_ui.py @@ -0,0 +1,40 @@ +# -*- encoding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.addons.website_sale_autocomplete.controllers.main import AutoCompleteController +from odoo.tests import patch, HttpCase, tagged + +CONTROLLER_PATH = 'odoo.addons.website_sale_autocomplete.controllers.main.AutoCompleteController' +MOCK_GOOGLE_ID = 'aHR0cHM6Ly93d3cueW91dHViZS5jb20vd2F0Y2g/dj1kUXc0dzlXZ1hjUQ==' +MOCK_API_KEY = 'Tm9ib2R5IGV4cGVjdHMgdGhlIFNwYW5pc2ggaW5xdWlzaXRpb24gIQ==' + + +@tagged('post_install', '-at_install') +class TestUI(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.product = cls.env['product.product'].create({ + 'name': 'A test product', + 'website_published': True, + 'list_price': 1 + }) + + def test_autocomplete(self): + with patch.object(AutoCompleteController, '_perform_complete_place_search', + lambda controller, *args, **kwargs: { + 'country': self.env['res.country'].search([('code', '=', 'USA')]).id, + 'state': self.env['res.country.state'].search([('country_id.code', '=', 'USA')])[0].id, + 'zip': '12345', + 'city': 'A Fictional City', + 'street': 'A fictional Street', + 'number': 42, + 'formatted_street_number': '42 A fictional Street' + }), \ + patch.object(AutoCompleteController, '_perform_place_search', + lambda controller, *args, **kwargs: { + 'results': [{ + 'formatted_address': f'Result {x}', + 'google_place_id': MOCK_GOOGLE_ID + } for x in range(5)]}): + self.env['website'].get_current_website().google_places_api_key = MOCK_API_KEY + self.start_tour('/shop/address', 'autocomplete_tour') diff --git a/addons/website_sale_autocomplete/views/res_config_settings_views.xml b/addons/website_sale_autocomplete/views/res_config_settings_views.xml new file mode 100644 index 00000000000..506074005e4 --- /dev/null +++ b/addons/website_sale_autocomplete/views/res_config_settings_views.xml @@ -0,0 +1,31 @@ + + + + + res.config.settings.view.form.inherit.autocomplete.googleplaces + res.config.settings + + + + + + + + + diff --git a/addons/website_sale_autocomplete/views/templates.xml b/addons/website_sale_autocomplete/views/templates.xml new file mode 100644 index 00000000000..91964079365 --- /dev/null +++ b/addons/website_sale_autocomplete/views/templates.xml @@ -0,0 +1,11 @@ + + + + +