Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FE changes to support location autocomplete within the NL Search bar #4649

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
4 changes: 4 additions & 0 deletions server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,10 @@ def register_routes_common(app):
from server.routes.shared_api import stats as shared_stats
app.register_blueprint(shared_stats.bp)

from server.routes.shared_api.autocomplete import \
autocomplete as shared_autocomplete
app.register_blueprint(shared_autocomplete.bp)

from server.routes.shared_api import variable as shared_variable
app.register_blueprint(shared_variable.bp)

Expand Down
66 changes: 66 additions & 0 deletions server/routes/shared_api/autocomplete/autocomplete.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Copyright 2020 Google LLC
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: 2024

#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import json
from urllib.parse import urlencode

from flask import Blueprint
from flask import request

from server.routes.shared_api.autocomplete import helpers
from server.routes.shared_api.place import findplacedcid

# TODO(gmechali): Add Stat Var search.

# Define blueprint
bp = Blueprint("autocomplete", __name__, url_prefix='/api')


@bp.route('/autocomplete', methods=['GET', 'POST'])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd use just GET here since the request is read-only and idempotent.

def autocomplete():
"""Predicts the user query for location only, using the Google Maps prediction API.
Returns:
Json object represnting 5 location predictions for the query.
"""
lang = request.args.get('hl')
original_query = request.args.get('query')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this just be query = ...?

query = original_query

# Extract subqueries from the user input.
queries = helpers.find_queries(query)

# Send requests to the Google Maps Predictions API.
prediction_responses = helpers.predict(queries, lang)

place_ids = []
for prediction in prediction_responses:
place_ids.append(prediction["place_id"])

place_id_to_dcid = []
if place_ids:
place_id_to_dcid = json.loads(findplacedcid(place_ids).data)

final_predictions = []
# TODO(gmechali): See if we can use typed dataclasses here.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

for prediction in prediction_responses:
current_prediction = {}
current_prediction['name'] = prediction['description']
current_prediction['match_type'] = 'location_search'
current_prediction['matched_query'] = prediction['matched_query']
if prediction['place_id'] in place_id_to_dcid:
current_prediction['dcid'] = place_id_to_dcid[prediction['place_id']]

final_predictions.append(current_prediction)

return {'predictions': final_predictions}
89 changes: 89 additions & 0 deletions server/routes/shared_api/autocomplete/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Copyright 2023 Google LLC
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: 2024

#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import json
from typing import List
from urllib.parse import urlencode

from flask import current_app
import requests

MAPS_API_URL = "https://maps.googleapis.com/maps/api/place/autocomplete/json?"
MIN_CHARACTERS_PER_QUERY = 3
MAX_NUM_OF_QUERIES = 4
RESPONSE_COUNT_LIMIT = 5


def find_queries(user_query: str):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: add typing def find_queries(user_query: str) -> List[str]:

(and to other functions below)

"""Extracts subqueries to send to the Google Maps Predictions API from the entire user input.
Returns:
List[str]: containing all subqueries to execute.
"""
words_in_query = user_query.split(" ")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: split on \s+ regex

queries = []
cumulative = ""
for word in reversed(words_in_query):
# Extract at most 3 subqueries.
if len(queries) >= MAX_NUM_OF_QUERIES:
break

# Prepend the current word for the next subquery.
if len(cumulative) > 0:
cumulative = word + " " + cumulative
else:
cumulative = word

# Only send queries 3 characters or longer.
if (len(cumulative) >= MIN_CHARACTERS_PER_QUERY):
queries.append(cumulative)

return queries


def execute_maps_request(query: str, language: str):
"""Execute a request to the Google Maps Prediction API for a given query.
Returns:
Json object containing the google maps prediction response.
"""
request_obj = {
'types': "(regions)",
'key': current_app.config['MAPS_API_KEY'],
'input': query,
'language': language
}
response = requests.post(MAPS_API_URL + urlencode(request_obj), json={})
return json.loads(response.text)


def predict(queries: List[str], lang: str):
"""Trigger maps prediction api requests and parse the output. Remove duplication responses and limit the number of results.
Returns:
Json object containing predictions from all queries issued after deduping.
"""
responses = []
place_ids = []
for query in queries:
predictions_for_query = execute_maps_request(query, lang)['predictions']
for pred in predictions_for_query:
if pred['place_id'] in place_ids:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Make place_ids a set

continue

pred['matched_query'] = query
responses.append(pred)
place_ids.append(pred['place_id'])

if len(responses) >= RESPONSE_COUNT_LIMIT:
return responses

return responses
19 changes: 11 additions & 8 deletions server/routes/shared_api/place.py
Original file line number Diff line number Diff line change
Expand Up @@ -676,14 +676,7 @@ def descendent_names():
return Response(json.dumps(result), 200, mimetype='application/json')


@bp.route('/placeid2dcid')
def placeid2dcid():
"""API endpoint to get dcid based on place id.

This is to use together with the Google Maps Autocomplete API:
https://developers.google.com/places/web-service/autocomplete.
"""
place_ids = request.args.getlist("placeIds")
def findplacedcid(place_ids):
if not place_ids:
return 'error: must provide `placeIds` field', 400
resp = fetch.resolve_id(place_ids, "placeId", "dcid")
Expand All @@ -697,6 +690,16 @@ def placeid2dcid():
return Response(json.dumps(result), 200, mimetype='application/json')


@bp.route('/placeid2dcid')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this endpoint still needed?

def placeid2dcid():
"""API endpoint to get dcid based on place id.
This is to use together with the Google Maps Autocomplete API:
https://developers.google.com/places/web-service/autocomplete.
"""
place_ids = request.args.getlist("placeIds")
return findplacedcid(place_ids)


@bp.route('/coords2places')
def coords2places():
"""API endpoint to get place name and dcid based on latitude/longitude
Expand Down
67 changes: 67 additions & 0 deletions server/tests/routes/api/autocomplete.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Copyright 2022 Google LLC
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2024

#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import json
import unittest
from unittest.mock import patch

import server.tests.routes.api.mock_data as mock_data
from web_app import app


class TestAutocomplete(unittest.TestCase):

def run_autocomplete_query(self, query: str, lang: str):
return app.test_client().post(
"/api/autocomplete?query=`${query}`&hl=${lang}", json={})

lang = 'en'

@patch('server.routes.shared_api.autocomplete.helpers.predict')
@patch('server.routes.shared_api.place.fetch.resolve_id')
def test_empty_query(self, mock_resolve_ids, mock_predict):

def resolve_ids_side_effect(nodes, in_prop, out_prop):
return []

def mock_predict_effect(query, lang):
return {}

mock_resolve_ids.side_effect = resolve_ids_side_effect
mock_predict.side_effect = mock_predict_effect

response = self.run_autocomplete_query('', 'en')
self.assertEqual(response.status_code, 200)

response_dict = json.loads(response.data.decode("utf-8"))
self.assertEqual(len(response_dict["predictions"]), 0)

@patch('server.routes.shared_api.autocomplete.helpers.predict')
@patch('server.routes.shared_api.place.fetch.resolve_id')
def test_single_word_query(self, mock_resolve_ids, mock_predict):

def resolve_ids_side_effect(nodes, in_prop, out_prop):
return mock_data.RESOLVE_IDS_VALUES

def mock_predict_effect(query, lang):
return mock_data.MAPS_PREDICTIONS_VALUES

mock_resolve_ids.side_effect = resolve_ids_side_effect
mock_predict.side_effect = mock_predict_effect

response = self.run_autocomplete_query('Calif', 'en')

self.assertEqual(response.status_code, 200)

response_dict = json.loads(response.data.decode("utf-8"))
self.assertEqual(len(response_dict["predictions"]), 5)
40 changes: 40 additions & 0 deletions server/tests/routes/api/mock_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,3 +415,43 @@
}
}
}

RESOLVE_IDS_VALUES = {
'ChIJPV4oX_65j4ARVW8IJ6IJUYs': [{
'dcid': 'geoId/4210768'
}],
'ChIJPV4oX_65j4ARVW8IJ6IJUYs1': [{
'dcid': 'geoId/4210769'
}],
'ChIJPV4oX_65j4ARVW8IJ6IJUYs2': [{
'dcid': 'geoId/4210770'
}],
'ChIJPV4oX_65j4ARVW8IJ6IJUYs3': [{
'dcid': 'geoId/4210771'
}],
'ChIJPV4oX_65j4ARVW8IJ6IJUYs4': [{
'dcid': 'geoId/4210772'
}]
}

MAPS_PREDICTIONS_VALUES = [{
'description': 'California, USA',
'place_id': 'ChIJPV4oX_65j4ARVW8IJ6IJUYs',
'matched_query': 'calif'
}, {
'description': 'Califon, NJ, USA',
'place_id': 'ChIJPV4oX_65j4ARVW8IJ6IJUYs1',
'matched_query': 'calif'
}, {
'description': 'California, MD, USA',
'place_id': 'ChIJPV4oX_65j4ARVW8IJ6IJUYs2',
'matched_query': 'calif'
}, {
'description': 'California City, CA, USA',
'place_id': 'ChIJPV4oX_65j4ARVW8IJ6IJUYs3',
'matched_query': 'calif'
}, {
'description': 'California, PA, USA',
'place_id': 'ChIJPV4oX_65j4ARVW8IJ6IJUYs4',
'matched_query': 'calif'
}]
25 changes: 25 additions & 0 deletions server/webdriver/tests/homepage_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,28 @@ def test_homepage_it(self):
# hero_msg = self.driver.find_elements(By.CLASS_NAME, 'lead')[0]
# self.assertTrue(
# hero_msg.text.startswith('Data Commons – это открытая база данных'))


# Tests for NL Search Bar AutoComplete feature.

def test_homepage_autocomplete(self):
"""Test homepage autocomplete."""

self.driver.get(self.url_ + '/?ac_on=true')

title_present = EC.text_to_be_present_in_element(
(By.CSS_SELECTOR, '#main-nav .navbar-brand'), 'Data Commons')
WebDriverWait(self.driver, self.TIMEOUT_SEC).until(title_present)

search_box_input = self.driver.find_element(By.ID, 'query-search-input')

# Type california into the search box.
search_box_input.send_keys("California")

suggestions_present = EC.presence_of_element_located(
(By.CLASS_NAME, 'search-input-result-section'))
WebDriverWait(self.driver, 300).until(suggestions_present)

autocomplete_results = self.driver.find_elements(
By.CLASS_NAME, 'search-input-result-section')
self.assertTrue(len(autocomplete_results) == 5)
Loading
Loading