/** @odoo-module **/ import { Model } from "@web/views/model"; import { session } from "@web/session"; import { browser } from "@web/core/browser/browser"; import { parseDate, parseDateTime } from "@web/core/l10n/dates"; import { KeepLast } from "@web/core/utils/concurrency"; const DATE_GROUP_FORMATS = { year: "yyyy", quarter: "'Q'q yyyy", month: "MMMM yyyy", week: "'W'WW yyyy", day: "dd MMM yyyy", }; export class MapModel extends Model { setup(params, { notification, http }) { this.notification = notification; this.http = http; this.metaData = { ...params, mapBoxToken: session.map_box_token || "", }; this.data = { count: 0, fetchingCoordinates: false, groupByKey: false, isGrouped: false, numberOfLocatedRecords: 0, partnerIds: [], partners: [], partnerToCache: [], recordGroups: [], records: [], routes: [], routingError: null, shouldUpdatePosition: true, useMapBoxAPI: !!this.metaData.mapBoxToken, }; this.coordinateFetchingTimeoutHandle = undefined; this.shouldFetchCoordinates = false; this.keepLast = new KeepLast(); } /** * @param {any} params * @returns {Promise} */ async load(params) { if (this.coordinateFetchingTimeoutHandle !== undefined) { this.stopFetchingCoordinates(); } const metaData = { ...this.metaData, ...params, }; this.data = await this._fetchData(metaData); this.metaData = metaData; this.notify(); } /** * Tells the model to stop fetching coordinates. * In OSM mode, the model starts to fetch coordinates once every second after the * model has loaded. * This fetching has to be done every second if we don't want to be banned from OSM. * There are typically two cases when we need to stop fetching: * - when component is about to be unmounted because the request is bound to * the component and it will crash if we do so. * - when calling the `load` method as it will start fetching new coordinates. */ stopFetchingCoordinates() { browser.clearTimeout(this.coordinateFetchingTimeoutHandle); this.coordinateFetchingTimeoutHandle = undefined; this.shouldFetchCoordinates = false; } //---------------------------------------------------------------------- // Protected //---------------------------------------------------------------------- /** * Adds the corresponding partner to a record. * * @protected */ _addPartnerToRecord(metaData, data) { for (const record of data.records) { for (const partner of data.partners) { let recordPartnerId; if (metaData.resModel === "res.partner" && metaData.resPartnerField === "id") { recordPartnerId = record.id; } else { recordPartnerId = record[metaData.resPartnerField][0]; } if (recordPartnerId == partner.id) { record.partner = partner; data.numberOfLocatedRecords++; } } } } /** * The partner's coordinates should be between -90 <= latitude <= 90 and -180 <= longitude <= 180. * * @protected * @param {Object} partner * @param {number} partner.partner_latitude latitude of the partner * @param {number} partner.partner_longitude longitude of the partner * @returns {boolean} */ _checkCoordinatesValidity(partner) { if ( partner.partner_latitude && partner.partner_longitude && partner.partner_latitude >= -90 && partner.partner_latitude <= 90 && partner.partner_longitude >= -180 && partner.partner_longitude <= 180 ) { return true; } return false; } /** * Handles the case of an empty map. * Handles the case where the model is res_partner. * Fetches the records according to the model given in the arch. * If the records has no partner_id field it is sliced from the array. * * @protected * @params {any} metaData * @return {Promise} */ async _fetchData(metaData) { const data = { count: 0, fetchingCoordinates: false, groupByKey: metaData.groupBy.length ? metaData.groupBy[0] : false, isGrouped: metaData.groupBy.length > 0, numberOfLocatedRecords: 0, partnerIds: [], partners: [], partnerToCache: [], recordGroups: [], records: [], routes: [], routingError: null, shouldUpdatePosition: true, useMapBoxAPI: !!metaData.mapBoxToken, }; //case of empty map if (!metaData.resPartnerField) { data.recordGroups = []; data.records = []; data.routes = []; return this.keepLast.add(Promise.resolve(data)); } const results = await this.keepLast.add(this._fetchRecordData(metaData, data)); data.records = results.records; data.count = results.length; if (data.isGrouped) { data.recordGroups = await this._getRecordGroups(metaData, data); } else { data.recordGroups = []; } data.partnerIds = []; if (metaData.resModel === "res.partner" && metaData.resPartnerField === "id") { for (const record of data.records) { data.partnerIds.push(record.id); record.partner_id = [record.id]; } } else { this._fillPartnerIds(metaData, data); } data.partnerIds = [...new Set(data.partnerIds)]; await this._partnerFetching(metaData, data); return data; } /** * Fetch the records for a given model. * * @protected * @returns {Promise} */ _fetchRecordData(metaData, data) { const fields = data.groupByKey ? metaData.fieldNames.concat(data.groupByKey.split(":")[0]) : metaData.fieldNames; const orderBy = []; if (metaData.defaultOrder) { orderBy.push(metaData.defaultOrder.name); if (metaData.defaultOrder.asc) { orderBy.push("ASC"); } } return this.orm.webSearchRead(metaData.resModel, metaData.domain, fields, { limit: metaData.limit, offset: metaData.offset, order: orderBy.join(" "), context: metaData.context, }); } /** * This function convert the addresses to coordinates using the mapbox API. * * @protected * @param {Object} record this object contains the record fetched from the database. * @returns {Promise} result.query contains the query the the api received * result.features contains results in descendant order of relevance */ _fetchCoordinatesFromAddressMB(metaData, data, record) { const address = encodeURIComponent(record.contact_address_complete); const token = metaData.mapBoxToken; const encodedUrl = `https://api.mapbox.com/geocoding/v5/mapbox.places/${address}.json?access_token=${token}&cachebuster=1552314159970&autocomplete=true`; return this.http.get(encodedUrl); } /** * This function convert the addresses to coordinates using the openStreetMap api. * * @protected * @param {Object} record this object contains the record fetched from the database. * @returns {Promise} result is an array that contains the result in descendant order of relevance * result[i].lat is the latitude of the converted address * result[i].lon is the longitude of the converted address * result[i].importance is a number that the relevance of the result the closer the number is to one the best it is. */ _fetchCoordinatesFromAddressOSM(metaData, data, record) { const address = encodeURIComponent(record.contact_address_complete.replace("/", " ")); const encodedUrl = `https://nominatim.openstreetmap.org/search/${address}?format=jsonv2`; return this.http.get(encodedUrl); } /** * @protected * @param {number[]} ids contains the ids from the partners * @returns {Promise} */ _fetchRecordsPartner(metaData, data, ids) { const domain = [ ["contact_address_complete", "!=", "False"], ["id", "in", ids], ]; const fields = ["contact_address_complete", "partner_latitude", "partner_longitude"]; return this.orm.searchRead("res.partner", domain, fields); } /** * Fetch the route from the mapbox api. * * @protected * @returns {Promise} * results.geometry.legs[i] contains one leg (i.e: the trip between two markers). * results.geometry.legs[i].steps contains the sets of coordinates to follow to reach a point from an other. * results.geometry.legs[i].distance: the distance in meters to reach the destination * results.geometry.legs[i].duration the duration of the leg * results.geometry.coordinates contains the sets of coordinates to go from the first to the last marker without the notion of waypoint */ _fetchRoute(metaData, data) { const coordinatesParam = data.records .filter((record) => record.partner.partner_latitude && record.partner.partner_longitude) .map(({ partner }) => `${partner.partner_longitude},${partner.partner_latitude}`); const address = encodeURIComponent(coordinatesParam.join(";")); const token = metaData.mapBoxToken; const encodedUrl = `https://api.mapbox.com/directions/v5/mapbox/driving/${address}?access_token=${token}&steps=true&geometries=geojson`; return this.http.get(encodedUrl); } /** * @protected * @param {Object[]} records the records that are going to be filtered */ _fillPartnerIds(metaData, data) { for (const record of data.records) { if (record[metaData.resPartnerField]) { data.partnerIds.push(record[metaData.resPartnerField][0]); } } } /** * Converts a MapBox error message into a custom translatable one. * * @protected * @param {string} message */ _getErrorMessage(message) { const ERROR_MESSAGES = { "Too many coordinates; maximum number of coordinates is 25": this.env._t( "Too many routing points (maximum 25)" ), "Route exceeds maximum distance limitation": this.env._t( "Some routing points are too far apart" ), "Too Many Requests": this.env._t("Too many requests, try again in a few minutes"), }; return ERROR_MESSAGES[message]; } /** * @protected * @returns {Object} the fetched records grouped by the groupBy field. */ async _getRecordGroups(metaData, data) { const [fieldName, subGroup] = data.groupByKey.split(":"); const groups = {}; const idToFetch = {}; const fieldType = metaData.fields[fieldName].type; for (const record of data.records) { const value = record[fieldName]; let id, name; if (["date", "datetime"].includes(fieldType) && value) { const date = fieldType === "date" ? parseDate(value) : parseDateTime(value); id = name = date.toFormat(DATE_GROUP_FORMATS[subGroup]); } else if (fieldType === "boolean") { id = name = value ? this.env._t("Yes") : this.env._t("No"); } else { id = Array.isArray(value) ? value[0] : value; name = Array.isArray(value) ? value[1] : value; } if (id === false && name === false) { id = name = this.env._t("None"); } if (["many2many", "one2many"].includes(fieldType) && value.length) { for (const m2mId of value) { idToFetch[m2mId] = undefined; } } else if (!groups[id]) { groups[id] = { name, records: [], }; } if (!["many2many", "one2many"].includes(fieldType) || !value.length) { groups[id].records.push(record); } } if (["many2many", "one2many"].includes(fieldType)) { const m2mList = await this.orm.nameGet( metaData.fields[fieldName].relation, Object.keys(idToFetch).map(Number) ); for (const [m2mId, m2mName] of m2mList) { idToFetch[m2mId] = m2mName; } for (const record of data.records) { for (const m2mId of record[fieldName]) { if (!groups[m2mId]) { groups[m2mId] = { name: idToFetch[m2mId], records: [], }; } groups[m2mId].records.push(record); } } } return groups; } /** * Handles the case where the selected api is MapBox. * Iterates on all the partners and fetches their coordinates when they're not set. * * @protected * @return {Promise} if there's more than 2 located records and the routing option is activated it returns a promise that fetches the route * resultResult is an object that contains the computed route * or if either of these conditions are not respected it returns an empty promise */ _maxBoxAPI(metaData, data) { const promises = []; for (const partner of data.partners) { if ( partner.contact_address_complete && (!partner.partner_latitude || !partner.partner_longitude) ) { promises.push( this._fetchCoordinatesFromAddressMB(metaData, data, partner).then( (coordinates) => { if (coordinates.features.length) { partner.partner_longitude = coordinates.features[0].geometry.coordinates[0]; partner.partner_latitude = coordinates.features[0].geometry.coordinates[1]; data.partnerToCache.push(partner); } } ) ); } else if (!this._checkCoordinatesValidity(partner)) { partner.partner_latitude = undefined; partner.partner_longitude = undefined; } } return Promise.all(promises).then(() => { data.routes = []; if (data.numberOfLocatedRecords > 1 && metaData.routing && !data.groupByKey) { return this._fetchRoute(metaData, data).then((routeResult) => { if (routeResult.routes) { data.routes = routeResult.routes; } else { data.routingError = this._getErrorMessage(routeResult.message); } }); } else { return Promise.resolve(); } }); } /** * Handles the displaying of error message according to the error. * * @protected * @param {Object} err contains the error returned by the requests * @param {number} err.status contains the status_code of the failed http request */ _mapBoxErrorHandling(metaData, data, err) { switch (err.status) { case 401: this.notification.add( this.env._t( "The view has switched to another provider but functionalities will be limited" ), { title: this.env._t("Token invalid"), type: "danger", } ); break; case 403: this.notification.add( this.env._t( "The view has switched to another provider but functionalities will be limited" ), { title: this.env._t("Unauthorized connection"), type: "danger", } ); break; case 422: // Max. addresses reached case 429: // Max. requests reached data.routingError = this._getErrorMessage(err.responseJSON.message); break; case 500: this.notification.add( this.env._t( "The view has switched to another provider but functionalities will be limited" ), { title: this.env._t("MapBox servers unreachable"), type: "danger", } ); } } /** * Notifies the fetched coordinates to server and controller. * * @protected */ _notifyFetchedCoordinate(metaData, data) { this._writeCoordinatesUsers(metaData, data); data.shouldUpdatePosition = false; this.notify(); } /** * Calls (without awaiting) _openStreetMapAPIAsync with a delay of 1000ms * to not get banned from openstreetmap's server. * * Tests should patch this function to wait for coords to be fetched. * * @see _openStreetMapAPIAsync * @protected * @return {Promise} */ _openStreetMapAPI(metaData, data) { this._openStreetMapAPIAsync(metaData, data); return Promise.resolve(); } /** * Handles the case where the selected api is open street map. * Iterates on all the partners and fetches their coordinates when they're not set. * * @protected * @returns {Promise} */ _openStreetMapAPIAsync(metaData, data) { // Group partners by address to reduce address list const addressPartnerMap = new Map(); for (const partner of data.partners) { if ( partner.contact_address_complete && (!partner.partner_latitude || !partner.partner_longitude) ) { if (!addressPartnerMap.has(partner.contact_address_complete)) { addressPartnerMap.set(partner.contact_address_complete, []); } addressPartnerMap.get(partner.contact_address_complete).push(partner); partner.fetchingCoordinate = true; } else if (!this._checkCoordinatesValidity(partner)) { partner.partner_latitude = undefined; partner.partner_longitude = undefined; } } // `fetchingCoordinates` is used to display the "fetching banner" // We need to check if there are coordinates to fetch before reload the // view to prevent flickering data.fetchingCoordinates = addressPartnerMap.size > 0; this.shouldFetchCoordinates = true; const fetch = async () => { const partnersList = Array.from(addressPartnerMap.values()); for (let i = 0; i < partnersList.length; i++) { await new Promise((resolve) => { this.coordinateFetchingTimeoutHandle = browser.setTimeout( resolve, this.constructor.COORDINATE_FETCH_DELAY ); }); if (!this.shouldFetchCoordinates) { return; } const partners = partnersList[i]; try { const coordinates = await this._fetchCoordinatesFromAddressOSM( metaData, data, partners[0] ); if (!this.shouldFetchCoordinates) { return; } if (coordinates.length) { for (const partner of partners) { partner.partner_longitude = coordinates[0].lon; partner.partner_latitude = coordinates[0].lat; data.partnerToCache.push(partner); } } for (const partner of partners) { partner.fetchingCoordinate = false; } data.fetchingCoordinates = i < partnersList.length - 1; this._notifyFetchedCoordinate(metaData, data); } catch (_e) { for (const partner of data.partners) { partner.fetchingCoordinate = false; } data.fetchingCoordinates = false; this.shouldFetchCoordinates = false; this.notification.add( this.env._t("OpenStreetMap's request limit exceeded, try again later."), { type: "danger" } ); this.notify(); } } }; return fetch(); } /** * Fetches the partner which ids are contained in the the array partnerids * if the token is set it uses the mapBoxApi to fetch address and route * if not is uses the openstreetmap api to fetch the address. * * @protected * @param {number[]} partnerIds this array contains the ids from the partner that are linked to records * @returns {Promise} */ async _partnerFetching(metaData, data) { data.partners = data.partnerIds.length ? await this.keepLast.add(this._fetchRecordsPartner(metaData, data, data.partnerIds)) : []; this._addPartnerToRecord(metaData, data); if (data.useMapBoxAPI) { return this.keepLast .add(this._maxBoxAPI(metaData, data)) .then(() => { this._writeCoordinatesUsers(metaData, data); }) .catch((err) => { this._mapBoxErrorHandling(metaData, data, err); data.useMapBoxAPI = false; return this._openStreetMapAPI(metaData, data); }); } else { return this._openStreetMapAPI(metaData, data).then(() => { this._writeCoordinatesUsers(metaData, data); }); } } /** * Writes partner_longitude and partner_latitude of the res.partner model. * * @protected * @return {Promise} */ async _writeCoordinatesUsers(metaData, data) { const partners = data.partnerToCache; data.partnerToCache = []; if (partners.length) { await this.orm.call("res.partner", "update_latitude_longitude", [partners], { context: metaData.context, }); } } } MapModel.services = ["notification", "http"]; MapModel.COORDINATE_FETCH_DELAY = 1000;