diff --git a/bemade_geo_distance/__init__.py b/bemade_geo_distance/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/bemade_geo_distance/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/bemade_geo_distance/__manifest__.py b/bemade_geo_distance/__manifest__.py new file mode 100644 index 0000000..6ed5473 --- /dev/null +++ b/bemade_geo_distance/__manifest__.py @@ -0,0 +1,35 @@ +# +# Bemade Inc. +# +# Copyright (C) July 2023 Bemade Inc. (). +# Author: Marc Durepos (Contact : marc@bemade.org) +# +# This program is under the terms of the Odoo Proprietary License v1.0 (OPL-1) +# It is forbidden to publish, distribute, sublicense, or sell copies of the Software +# or modified copies of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. +# +{ + 'name': 'Bemade Geo Distance', + 'version': '15.0.1.0.0', + 'summary': 'Utility module for calculating the distance driving between addresses.', + 'description': """Module for calculating the driving distance between addresses using the Google Route API. + **IMPORTANT NOTE:** You must have an active Google Route API key configured in the Geo Localization + settings (under General Settings).""", + 'category': 'Generic Modules/Others', + 'author': 'Bemade Inc.', + 'website': 'https://www.bemade.org', + 'license': 'OPL-1', + 'depends': ['base_geolocalize'], + 'data': [], + 'demo': [], + 'installable': True, + 'auto_install': False, +} diff --git a/bemade_geo_distance/models/__init__.py b/bemade_geo_distance/models/__init__.py new file mode 100644 index 0000000..ecf0446 --- /dev/null +++ b/bemade_geo_distance/models/__init__.py @@ -0,0 +1 @@ +from . import bemade_geo_router diff --git a/bemade_geo_distance/models/bemade_geo_router.py b/bemade_geo_distance/models/bemade_geo_router.py new file mode 100644 index 0000000..638d7ed --- /dev/null +++ b/bemade_geo_distance/models/bemade_geo_router.py @@ -0,0 +1,95 @@ +from odoo import models, fields, tools, _, api +from odoo.exceptions import UserError +import requests +import logging +import datetime +from typing import Literal + +_logger = logging.getLogger(__name__) + + +class GeoRouter(models.AbstractModel): + """ Abstract class used to call Google Route API and convert responses + into useful distance and route data. + """ + _name = 'base.geo_router' + _description = "Geographic Router" + + units = Literal['metric', 'imperial'] + + @api.model + def _get_api_key(self): + apikey = self.env['ir.config_parameter'].sudo().get_param('base_geolocalize.google_map_api_key') + if not apikey: + raise UserError(_( + "API key for GeoCoding (Places) required.\n" + "Visit https://developers.google.com/maps/documentation/geocoding/get-api-key for more information." + )) + return apikey + + @api.model + def get_driving_distance_time(self, origin, destination, departure_time: datetime.datetime = None, + arrival_time: datetime.date = None, units: units = 'metric', + avoid_tolls=False, avoid_highways=False, avoid_ferries=False) -> (float, float): + """ Calculates the route between two addresses and returns the distance it either in Kilometers or Miles and + the time in minutes. + + :param origin: The origin address (res.partner) from which to drive (origin). + :param destination: The destination address (res.partner). + :param departure_time: The departure time to use when planning the route (traffic aware). + Cannot be set simultaneously with arrival_time. + :param arrival_time: The arrival time to use when planning the route (traffic aware). + Cannot be set simultaneously with departure_time. + :param units: Type of units to return: 'imperial' for Miles, 'metric' for Kilometers. + :param avoid_tolls: Whether Google should calculate a route avoiding tolls. + :param avoid_highways: Whether Google should calculate a route avoiding highways. + :param avoid_ferries: Whether Google should calculate a route avoiding ferries. + :returns: tuple(distance: float, time: float) + """ + if departure_time and arrival_time: + raise ValueError("Route cannot be calculated with both an arrival time and departure time.") + apikey = self._get_api_key() + origin_addr = self.env['base.geocoder'].geo_query_address(street=origin.street, zip=origin.zip, + city=origin.city, state=origin.state_id.name, + country=origin.country_id.name) + destination_addr = self.env['base.geocoder'].geo_query_address(street=destination.street, zip=destination.zip, + city=destination.city, + state=destination.state_id.name, + country=destination.country_id.name) + params = { + 'origin': { + 'address': origin_addr, + }, + 'destination': { + 'address': destination_addr, + }, + 'travelMode': 'DRIVE', + 'computeAlternativeRoutes': False, + 'routeModifiers': { + 'avoidTolls': avoid_tolls, + 'avoidHighways': avoid_highways, + 'avoidFerries': avoid_ferries, + }, + 'languageCode': 'en-US', + 'units': units, + } + if departure_time or arrival_time: + params.update({ + 'routingPreference': 'TRAFFIC_AWARE', + 'departureTime' if departure_time else 'arrivalTime': departure_time or arrival_time + }) + + url = "https://routes.googleapis.com/directions/v2:computeRoutes" + distance_field = 'distanceMeters' if units == 'metric' else 'distanceMiles' + result = requests.post(url, + json=params, + headers={'X-Goog-Api-Key': apikey, + 'Content-Type': 'application/json', + 'X-Goog-FieldMask': f"routes.duration,routes.{distance_field}"}).json() + if 'routes' not in result: + _logger.error(f"Failed to retrieve route between {origin_addr} and {destination_addr}. " + f"Google response: {result}.") + route = result['routes'][0] + distance = (route[distance_field] / 1000) if units == 'metric' else route[distance_field] + time = int(route['duration'].strip('s')) / 60 + return distance, time diff --git a/bemade_geo_distance/tests/__init__.py b/bemade_geo_distance/tests/__init__.py new file mode 100644 index 0000000..51717cf --- /dev/null +++ b/bemade_geo_distance/tests/__init__.py @@ -0,0 +1 @@ +from . import test_geo_router diff --git a/bemade_geo_distance/tests/test_geo_router.py b/bemade_geo_distance/tests/test_geo_router.py new file mode 100644 index 0000000..dd60f01 --- /dev/null +++ b/bemade_geo_distance/tests/test_geo_router.py @@ -0,0 +1,53 @@ +from odoo.tests import TransactionCase, tagged +import datetime +import pytz + +@tagged('-at_install', 'post_install') +class TestGeoRouter(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.apikey = cls.env['base.geo_router']._get_api_key() + + def test_distance_time_basic(self): + bemade, montreal = self._generate_partners() + + distance, time = self.env['base.geo_router'].get_driving_distance_time(bemade, montreal) + + self.assertTrue(20 < distance < 40) + self.assertTrue(12 < time < 60) + + def test_time_traffic(self): + bemade, montreal = self._generate_partners() + est = pytz.timezone('US/Eastern') + today_naive = datetime.date.today() + today = datetime.datetime(today_naive.year, today_naive.month, today_naive.day, 8, 30, tzinfo=est) + arrival_time_local = today + datetime.timedelta(days=(2 - today.weekday()) % 7) + arrival_time_utc = arrival_time_local.astimezone(pytz.utc) + arrival_time = arrival_time_utc.strftime('%Y-%m-%dT%H:%M:%S.%fZ') + print(arrival_time) + + distance, time = self.env['base.geo_router'].get_driving_distance_time(bemade, montreal, + arrival_time=arrival_time) + + self.assertTrue(25 < time < 90) + + @classmethod + def _generate_partners(self): + bemade = self.env['res.partner'].create({ + 'name': 'Bemade Inc.', + 'street': '255 North Montcalm Blvd.', + 'city': 'Candiac', + 'zip': 'J5R 3L6', + 'state_id': self.env.ref('base.state_ca_qc').id, + 'country_id': self.env.ref('base.ca').id, + }) + montreal = self.env['res.partner'].create({ + 'name': 'City of Montreal', + 'street': '220 East René-Lévesque Blvd.', + 'city': 'Montreal', + 'zip': 'H2X 3A7', + 'state_id': self.env.ref('base.state_ca_qc').id, + 'country_id': self.env.ref('base.ca').id, + }) + return bemade, montreal