[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:
committed by
Yannick Tivisse
parent
5b5150dfa5
commit
ba988f7e6b
@@ -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>
|
||||
Reference in New Issue
Block a user