diff --git a/frappe/boot.py b/frappe/boot.py index 240924291b..49c5983e81 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -111,6 +111,9 @@ def get_bootinfo(): bootinfo.subscription_conf = add_subscription_conf() bootinfo.marketplace_apps = get_marketplace_apps() bootinfo.changelog_feed = get_changelog_feed_items() + bootinfo.enable_address_autocompletion = frappe.db.get_single_value( + "Geolocation Settings", "enable_address_autocompletion" + ) if sentry_dsn := get_sentry_dsn(): bootinfo.sentry_dsn = sentry_dsn diff --git a/frappe/integrations/doctype/geolocation_settings/__init__.py b/frappe/integrations/doctype/geolocation_settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/integrations/doctype/geolocation_settings/geolocation_settings.js b/frappe/integrations/doctype/geolocation_settings/geolocation_settings.js new file mode 100644 index 0000000000..269b388265 --- /dev/null +++ b/frappe/integrations/doctype/geolocation_settings/geolocation_settings.js @@ -0,0 +1,8 @@ +// Copyright (c) 2024, Frappe Technologies and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Geolocation Settings", { +// refresh(frm) { + +// }, +// }); diff --git a/frappe/integrations/doctype/geolocation_settings/geolocation_settings.json b/frappe/integrations/doctype/geolocation_settings/geolocation_settings.json new file mode 100644 index 0000000000..c172ba44c7 --- /dev/null +++ b/frappe/integrations/doctype/geolocation_settings/geolocation_settings.json @@ -0,0 +1,64 @@ +{ + "actions": [], + "creation": "2024-04-09 23:41:49.747820", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "enable_address_autocompletion", + "provider", + "base_url", + "api_key" + ], + "fields": [ + { + "fieldname": "provider", + "fieldtype": "Select", + "label": "Provider", + "mandatory_depends_on": "enable_address_autocompletion", + "options": "Geoapify\nNomatim\nHERE" + }, + { + "depends_on": "eval: [\"Geoapify\", \"HERE\"].includes(doc.provider)", + "fieldname": "api_key", + "fieldtype": "Password", + "label": "API Key", + "mandatory_depends_on": "eval: doc.enable_address_autocompletion && [\"Geoapify\", \"HERE\"].includes(doc.provider)" + }, + { + "default": "0", + "fieldname": "enable_address_autocompletion", + "fieldtype": "Check", + "label": "Enable Address Autocompletion" + }, + { + "depends_on": "eval: doc.provider === \"Nomatim\"", + "fieldname": "base_url", + "fieldtype": "Data", + "label": "Base URL", + "mandatory_depends_on": "eval: doc.provider === \"Nomatim\" && doc.enable_address_autocompletion", + "options": "URL" + } + ], + "issingle": 1, + "links": [], + "modified": "2024-06-22 09:18:34.306542", + "modified_by": "Administrator", + "module": "Integrations", + "name": "Geolocation Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/frappe/integrations/doctype/geolocation_settings/geolocation_settings.py b/frappe/integrations/doctype/geolocation_settings/geolocation_settings.py new file mode 100644 index 0000000000..0959b58c7a --- /dev/null +++ b/frappe/integrations/doctype/geolocation_settings/geolocation_settings.py @@ -0,0 +1,54 @@ +# Copyright (c) 2024, Frappe Technologies and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import get_url + +from .providers.geoapify import Geoapify +from .providers.here import Here +from .providers.nomatim import Nomatim + + +class GeolocationSettings(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + api_key: DF.Password | None + base_url: DF.Data | None + enable_address_autocompletion: DF.Check + provider: DF.Literal["Geoapify", "Nomatim", "HERE"] + # end: auto-generated types + + pass + + +@frappe.whitelist() +def autocomplete(txt: str) -> list[dict]: + if not txt: + return [] + + settings = frappe.get_single("Geolocation Settings") + if not settings.enable_address_autocompletion: + return [] + + if settings.provider == "Geoapify": + provider = Geoapify(settings.get_password("api_key"), frappe.local.lang) + elif settings.provider == "Nomatim": + provider = Nomatim( + base_url=settings.base_url, + referer=get_url(), + lang=frappe.local.lang, + ) + elif settings.provider == "HERE": + provider = Here(settings.get_password("api_key"), frappe.local.lang) + else: + frappe.throw(_("This geolocation provider is not supported yet.")) + + return list(provider.autocomplete(txt)) diff --git a/frappe/integrations/doctype/geolocation_settings/providers/geoapify.py b/frappe/integrations/doctype/geolocation_settings/providers/geoapify.py new file mode 100644 index 0000000000..8cdd8a3d20 --- /dev/null +++ b/frappe/integrations/doctype/geolocation_settings/providers/geoapify.py @@ -0,0 +1,36 @@ +import json + +import requests + + +class Geoapify: + def __init__(self, api_key: str, lang: str | None = None): + self.api_key = api_key + self.lang = lang + self.base_url = "https://api.geoapify.com" + + def autocomplete(self, query: str): + params = { + "text": query, + "apiKey": self.api_key, + "limit": 5, + "format": "json", + "lang": self.lang, + } + response = requests.get(f"{self.base_url}/v1/geocode/autocomplete", params=params) + response.raise_for_status() + + results = response.json()["results"] + for result in results: + yield { + "label": result["formatted"], + "value": json.dumps( + { + "address_line1": result.get("address_line1"), + "city": result.get("city"), + "state": result.get("state"), + "pincode": result.get("postcode"), + "country": result.get("country"), + } + ), + } diff --git a/frappe/integrations/doctype/geolocation_settings/providers/here.py b/frappe/integrations/doctype/geolocation_settings/providers/here.py new file mode 100644 index 0000000000..0ecac02d9c --- /dev/null +++ b/frappe/integrations/doctype/geolocation_settings/providers/here.py @@ -0,0 +1,44 @@ +import json + +import pycountry +import requests + +import frappe + + +class Here: + def __init__(self, api_key: str, lang: str | None = None): + self.lang = lang + self.api_key = api_key + self.base_url = "https://autocomplete.search.hereapi.com/v1" + + def autocomplete(self, query: str): + params = { + "q": query, + "apiKey": self.api_key, + "limit": 5, + "lang": self.lang, + } + response = requests.get( + f"{self.base_url}/autocomplete", + params=params, + ) + response.raise_for_status() + + results = response.json()["items"] + for result in results: + address = result["address"] + py_country = pycountry.countries.get(alpha_3=address.get("countryCode")) + frappe_country = frappe.db.get_value("Country", {"code": py_country.alpha_2.lower()}) + yield { + "label": address["label"], + "value": json.dumps( + { + "address_line1": f'{address.get("street", "")} {address.get("houseNumber", "")}'.strip(), + "city": address.get("city", ""), + "state": address.get("state", ""), + "pincode": address.get("postalCode", ""), + "country": frappe_country or "", + } + ), + } diff --git a/frappe/integrations/doctype/geolocation_settings/providers/nomatim.py b/frappe/integrations/doctype/geolocation_settings/providers/nomatim.py new file mode 100644 index 0000000000..c02e14ad68 --- /dev/null +++ b/frappe/integrations/doctype/geolocation_settings/providers/nomatim.py @@ -0,0 +1,47 @@ +import json + +import requests + +import frappe + + +class Nomatim: + def __init__(self, base_url: str, referer: str, lang: str | None = None): + self.lang = lang + self.referer = referer + self.base_url = base_url + + def autocomplete(self, query: str): + params = { + "q": query, + "format": "json", + "limit": 5, + "addressdetails": 1, + "accept-language": self.lang, + "layer": "address", + } + response = requests.get( + f"{self.base_url}/search", + params=params, + headers={"Referer": self.referer}, + ) + response.raise_for_status() + + results = response.json() + for result in results: + if "address" not in result: + continue + + address = result["address"] + yield { + "label": result["display_name"], + "value": json.dumps( + { + "address_line1": f'{address.get("road")} {address.get("house_number", "")}'.strip(), + "city": address.get("city") or address.get("town") or address.get("village"), + "state": address.get("state"), + "pincode": address.get("postcode"), + "country": frappe.db.get_value("Country", {"code": address.get("country_code")}), + } + ), + } diff --git a/frappe/integrations/doctype/geolocation_settings/test_geolocation_settings.py b/frappe/integrations/doctype/geolocation_settings/test_geolocation_settings.py new file mode 100644 index 0000000000..90979b2949 --- /dev/null +++ b/frappe/integrations/doctype/geolocation_settings/test_geolocation_settings.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Frappe Technologies and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestGeolocationSettings(FrappeTestCase): + pass diff --git a/frappe/public/js/desk.bundle.js b/frappe/public/js/desk.bundle.js index e9f841c41f..7053a1d682 100644 --- a/frappe/public/js/desk.bundle.js +++ b/frappe/public/js/desk.bundle.js @@ -104,3 +104,5 @@ import "./frappe/ui/chart.js"; import "./frappe/ui/datatable.js"; import "./frappe/ui/driver.js"; import "./frappe/scanner"; + +import "./frappe/ui/address_autocomplete/autocomplete_dialog.js"; diff --git a/frappe/public/js/frappe/ui/address_autocomplete/autocomplete_dialog.js b/frappe/public/js/frappe/ui/address_autocomplete/autocomplete_dialog.js new file mode 100644 index 0000000000..53d8405ceb --- /dev/null +++ b/frappe/public/js/frappe/ui/address_autocomplete/autocomplete_dialog.js @@ -0,0 +1,87 @@ +frappe.provide("frappe.ui"); + +frappe.ui.AddressAutocompleteDialog = class AddressAutocompleteDialog { + constructor(opts) { + this.title = opts?.title || __("New Address"); + this.link_doctype = opts?.link_doctype; + this.link_name = opts?.link_name; + this.after_insert = opts?.after_insert; + this.dialog = this._get_dialog(); + } + + _get_dialog() { + // sourcery skip: inline-immediately-returned-variable + const dialog = new frappe.ui.Dialog({ + title: this.title, + fields: [ + { + fieldname: "search", + fieldtype: "Autocomplete", + label: __("Search"), + reqd: 1, + get_query: + "frappe.integrations.doctype.geolocation_settings.geolocation_settings.autocomplete", + onchange: () => { + // Disable "Create Address" button if mandatory fields are missing + frappe.model.with_doctype("Address", () => { + const address = this.parse_selected_value(); + const mandatory_fields = frappe + .get_meta("Address") + .fields.filter( + (field) => + field.reqd && + !field.default && + field.fieldname !== "address_type" + ); + const missing_fields = mandatory_fields.filter( + (field) => !address[field.fieldname] + ); + const is_valid = missing_fields.length === 0; + if (is_valid) { + dialog.enable_primary_action(); + } else { + dialog.disable_primary_action(); + } + }); + }, + }, + ], + primary_action_label: __("Create Address"), + primary_action: () => { + // Insert the address into the database + dialog.hide(); + + const address = this.parse_selected_value(); + address["doctype"] = "Address"; + address["links"] = [ + { + link_doctype: this.link_doctype, + link_name: this.link_name, + }, + ]; + frappe.db.insert(address).then((doc) => { + this.after_insert && this.after_insert(doc); + }); + }, + secondary_action_label: __("Edit Address in Form"), + secondary_action: () => { + // Open the address in the form view + dialog.hide(); + + const address = this.parse_selected_value(); + frappe.new_doc("Address", address); + }, + }); + + return dialog; + } + + parse_selected_value() { + const data = this.dialog.get_values(); + return JSON.parse(data.search); + } + + show() { + this.dialog.show(); + } +}; diff --git a/frappe/public/js/frappe/utils/address_and_contact.js b/frappe/public/js/frappe/utils/address_and_contact.js index 1cea4d1514..5e3217366f 100644 --- a/frappe/public/js/frappe/utils/address_and_contact.js +++ b/frappe/public/js/frappe/utils/address_and_contact.js @@ -12,7 +12,7 @@ $.extend(frappe.contacts, { $(frm.fields_dict["address_html"].wrapper) .html(frappe.render_template("address_list", frm.doc.__onload)) .find(".btn-address") - .on("click", () => new_record("Address", frm.doc)); + .on("click", () => new_record("Address", frm)); } // render contact @@ -20,7 +20,7 @@ $.extend(frappe.contacts, { $(frm.fields_dict["contact_html"].wrapper) .html(frappe.render_template("contact_list", frm.doc.__onload)) .find(".btn-contact") - .on("click", () => new_record("Contact", frm.doc)); + .on("click", () => new_record("Contact", frm)); } }, get_last_doc: function (frm) { @@ -59,12 +59,23 @@ $.extend(frappe.contacts, { }, }); -function new_record(doctype, source_doc) { +function new_record(doctype, frm) { frappe.dynamic_link = { - doctype: source_doc.doctype, - doc: source_doc, + doctype: frm.doc.doctype, + doc: frm.doc, fieldname: "name", }; - return frappe.new_doc(doctype); + if (frappe.boot.enable_address_autocompletion === 1 && doctype === "Address") { + new frappe.ui.AddressAutocompleteDialog({ + title: __("New Address"), + link_doctype: frm.doc.doctype, + link_name: frm.doc.name, + after_insert: function (doc) { + frm.reload_doc(); + }, + }).show(); + } else { + frappe.new_doc(doctype); + } } diff --git a/pyproject.toml b/pyproject.toml index 206e2b534b..ef4ce7f7d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,6 +88,7 @@ dependencies = [ "google-auth~=1.29.0", "posthog~=3.0.1", "vobject~=0.9.7", + "pycountry~=24.6.1", ] [project.urls]