[ADD] website_sale_autocomplete : Autocomplete the address fields in website using Google Places.

task-id: 2806842
PR : https://github.com/odoo/odoo/pull/87636

- Implement an 'address.provider' structure, designed to be extendable and support multiple providers (not just google)
- Add a module website_sale_autocompletion, that could be relocated without too much hassle
- Add a module autocompletion_google_places thats adds google functionalities on top of the previous module

closes odoo/odoo#88782

Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
Bastien Dosimont
2022-05-25 10:42:47 +02:00
committed by Yannick Tivisse
parent 5b5150dfa5
commit ba988f7e6b
18 changed files with 514 additions and 0 deletions
@@ -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')
@@ -393,6 +393,20 @@
</div>
</div>
</div>
<xpath expr="//div[@id='google_analytics_setting']" position="after">
<div class="col-12 col-lg-6 o_setting_box" id="autocomplete_googleplaces_setting">
<div class="o_setting_left_pane">
<field name="module_website_sale_autocomplete"/>
</div>
<div class="o_setting_right_pane">
<label for="module_website_sale_autocomplete"/>
<div class="text-muted">
Use Google Places API to validate addresses entered by your visitors
</div>
</div>
</div>
</xpath>
</field>
</record>
</odoo>
@@ -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
@@ -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',
}
@@ -0,0 +1,4 @@
# -*- encoding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
@@ -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)
@@ -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
@@ -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)
@@ -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")
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -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();
},
});
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates>
<t t-name="website_sale_autocomplete.AutocompleteDropDown">
<div t-attf-class="dropdown-menu w-100 #{results ? 'show' : ''}">
<a class="dropdown-item js_autocomplete_result"
t-foreach="results" t-as="result"
t-att-data-google-place-id="result['google_place_id']">
<t t-out="result['formatted_address']"/>
</a>
<img class="p-2" src="/website_sale_autocomplete/static/src/img/powered_by_google_on_white.png" alt="Powered by Google"/>
</div>
</t>
</templates>
@@ -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())
}
}
}]);
@@ -0,0 +1,4 @@
# -*- encoding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_ui
@@ -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')
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="res_config_settings_view_form_inherit_autocomplete_googleplaces" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.autocomplete.googleplaces</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="website.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@id='autocomplete_googleplaces_setting']/div[hasclass('o_setting_right_pane')]" position="inside">
<div>
<div class="content-group row mt16">
<label class="col-4" for="google_places_api_key" string="API Key"/>
<field class="col-6" name="google_places_api_key"/>
</div>
<div class="mt8">
<a target="_blank" href="https://console.cloud.google.com/getting-started">
<i class="fa fa-arrow-right"/>
Create a Google Project and get a key
</a>
<br/>
<a target="_blank" href="https://console.cloud.google.com/billing">
<i class="fa fa-arrow-right"/>
Enable billing on your Google Project
</a>
</div>
</div>
</xpath>
</field>
</record>
</odoo>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<template id="website_sale_address_with_autocomplete" inherit_id="website_sale.address">
<xpath expr="//input[@name='street']" position="attributes">
<attribute name="t-att-data-autocomplete-enabled">
1 if website.sudo().google_places_api_key else 0
</attribute>
</xpath>
</template>
</odoo>