diff --git a/frappe/contacts/doctype/contact/contact.js b/frappe/contacts/doctype/contact/contact.js index c6ad8cc269..9e9b0248d9 100644 --- a/frappe/contacts/doctype/contact/contact.js +++ b/frappe/contacts/doctype/contact/contact.js @@ -61,6 +61,15 @@ frappe.ui.form.on("Contact", { } } ]); + }, + sync_with_google_contacts: function(frm) { + if (frm.doc.sync_with_google_contacts) { + frappe.db.get_value("Google Contacts", {"email_id": frappe.session.user}, "name", (r) => { + if (r && r.name) { + frm.set_value("google_contacts", r.name); + } + }) + } } }); diff --git a/frappe/contacts/doctype/contact/contact.json b/frappe/contacts/doctype/contact/contact.json index f700411e80..85986dd9d5 100644 --- a/frappe/contacts/doctype/contact/contact.json +++ b/frappe/contacts/doctype/contact/contact.json @@ -14,14 +14,21 @@ "email_id", "user", "address", + "sync_with_google_contacts", "cb00", "status", "salutation", "designation", "gender", "phone", + "company_name", "image", "sb_00", + "google_contacts", + "google_contacts_id", + "cb_00", + "pulled_from_google_contacts", + "sb_01", "email_ids", "phone_nos", "contact_details", @@ -29,10 +36,7 @@ "links", "more_info", "department", - "unsubscribed", - "column_break_17", - "source", - "google_contacts_description" + "unsubscribed" ], "fields": [ { @@ -155,36 +159,23 @@ "fieldtype": "Data", "label": "Designation" }, - { - "fieldname": "column_break_17", - "fieldtype": "Column Break" - }, { "default": "0", "fieldname": "unsubscribed", "fieldtype": "Check", "label": "Unsubscribed" }, - { - "fieldname": "source", - "fieldtype": "Data", - "label": "Source" - }, { "fieldname": "middle_name", "fieldtype": "Data", "label": "Middle Name" }, { - "depends_on": "eval:doc.source==\"Google Contacts\"", - "fieldname": "google_contacts_description", - "fieldtype": "Small Text", - "label": "Google Contacts Description" - }, - { + "collapsible": 1, + "depends_on": "eval:doc.sync_with_google_contacts || doc.pulled_from_google_contacts", "fieldname": "sb_00", "fieldtype": "Section Break", - "label": "Contact Details" + "label": "Google Contacts" }, { "fieldname": "email_ids", @@ -203,13 +194,52 @@ "fieldtype": "Table", "label": "Phone Nos", "options": "Contact Phone" + }, + { + "default": "0", + "fieldname": "pulled_from_google_contacts", + "fieldtype": "Check", + "label": "Pulled from Google Contacts", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "sync_with_google_contacts", + "fieldtype": "Check", + "label": "Sync with Google Contacts" + }, + { + "fieldname": "google_contacts", + "fieldtype": "Link", + "label": "Google Contacts", + "options": "Google Contacts" + }, + { + "fieldname": "cb_00", + "fieldtype": "Column Break" + }, + { + "fieldname": "sb_01", + "fieldtype": "Section Break", + "label": "Contact Details" + }, + { + "fieldname": "google_contacts_id", + "fieldtype": "Data", + "label": "Google Contacts Id", + "read_only": 1 + }, + { + "fieldname": "company_name", + "fieldtype": "Data", + "label": "Company Name" } ], "icon": "fa fa-user", "idx": 1, "image_field": "image", - "modified": "2019-08-09 10:23:00.486673", - "modified_by": "Administrator", + "modified": "2019-09-13 15:50:38.999884", + "modified_by": "himanshu@erpnext.com", "module": "Contacts", "name": "Contact", "name_case": "Title Case", diff --git a/frappe/contacts/doctype/contact/contact.py b/frappe/contacts/doctype/contact/contact.py index 085452988a..6857d013a1 100644 --- a/frappe/contacts/doctype/contact/contact.py +++ b/frappe/contacts/doctype/contact/contact.py @@ -42,6 +42,9 @@ class Contact(Document): if self.email_id and not self.image: self.image = has_gravatar(self.email_id) + if self.sync_with_google_contacts and not self.google_contacts: + frappe.throw(_("Select Google Contacts to which contact should be synced.")) + deduplicate_dynamic_links(self) def set_user(self): diff --git a/frappe/hooks.py b/frappe/hooks.py index eb0d5bbe77..d2e8570644 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -136,6 +136,15 @@ doc_events = { "on_change": [ "frappe.social.doctype.energy_point_rule.energy_point_rule.process_energy_points" ], + }, + "Event": { + "after_insert": "frappe.integrations.doctype.google_calendar.google_calendar.insert_event_in_google_calendar", + "on_update": "frappe.integrations.doctype.google_calendar.google_calendar.update_event_in_google_calendar", + "on_trash": "frappe.integrations.doctype.google_calendar.google_calendar.delete_event_from_google_calendar", + }, + "Contact": { + "after_insert": "frappe.integrations.doctype.google_contacts.google_contacts.insert_contacts_to_google_contacts", + "on_update": "frappe.integrations.doctype.google_contacts.google_contacts.update_contacts_to_google_contacts", } } diff --git a/frappe/integrations/doctype/google_contacts/google_contacts.json b/frappe/integrations/doctype/google_contacts/google_contacts.json index 42fb9e68c8..1089c6b635 100644 --- a/frappe/integrations/doctype/google_contacts/google_contacts.json +++ b/frappe/integrations/doctype/google_contacts/google_contacts.json @@ -11,7 +11,12 @@ "cb_00", "last_sync_on", "authorization_code", - "refresh_token" + "refresh_token", + "next_sync_token", + "sync", + "pull_from_google_contacts", + "column_break_12", + "push_to_google_contacts" ], "fields": [ { @@ -62,10 +67,38 @@ "fieldname": "authorize_google_contacts_access", "fieldtype": "Button", "label": "Authorize Google Contacts Access" + }, + { + "fieldname": "next_sync_token", + "fieldtype": "Password", + "hidden": 1, + "label": "Next Sync Token" + }, + { + "depends_on": "enable", + "fieldname": "sync", + "fieldtype": "Section Break", + "label": "Sync" + }, + { + "default": "0", + "fieldname": "pull_from_google_contacts", + "fieldtype": "Check", + "label": "Pull from Google Contacts" + }, + { + "fieldname": "column_break_12", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "push_to_google_contacts", + "fieldtype": "Check", + "label": "Push to Google Contacts" } ], - "modified": "2019-08-23 13:50:52.789503", - "modified_by": "Administrator", + "modified": "2019-09-13 15:53:19.569924", + "modified_by": "himanshu@erpnext.com", "module": "Integrations", "name": "Google Contacts", "owner": "Administrator", diff --git a/frappe/integrations/doctype/google_contacts/google_contacts.py b/frappe/integrations/doctype/google_contacts/google_contacts.py index 4955fc9d7f..738c097f63 100644 --- a/frappe/integrations/doctype/google_contacts/google_contacts.py +++ b/frappe/integrations/doctype/google_contacts/google_contacts.py @@ -5,14 +5,16 @@ from __future__ import unicode_literals import frappe import requests +import googleapiclient.discovery +import google.oauth2.credentials + from frappe.model.document import Document from frappe import _ +from googleapiclient.errors import HttpError from frappe.utils import get_request_site_address from frappe.integrations.doctype.google_settings.google_settings import get_auth_url SCOPES = "https://www.googleapis.com/auth/contacts" -REQUEST = "https://people.googleapis.com/v1/people/me/connections" -PARAMS = {"personFields": "names,emailAddresses,organizations,phoneNumbers"} class GoogleContacts(Document): @@ -60,7 +62,7 @@ def authorize_access(g_contact, reauthorize=None): if not google_contact.authorization_code or reauthorize: frappe.cache().hset("google_contacts", "google_contact", google_contact.name) - return google_callback(client_id=google_settings.client_id, redirect_uri=redirect_uri) + return get_authentication_url(client_id=google_settings.client_id, redirect_uri=redirect_uri) else: try: data = { @@ -83,21 +85,42 @@ def authorize_access(g_contact, reauthorize=None): except Exception as e: frappe.throw(e) +def get_authentication_url(client_id=None, redirect_uri=None): + return { + "url": "https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&response_type=code&prompt=consent&client_id={}&include_granted_scopes=true&scope={}&redirect_uri={}".format(client_id, SCOPES, redirect_uri) + } + @frappe.whitelist() -def google_callback(client_id=None, redirect_uri=None, code=None): +def google_callback(code=None): """ Authorization code is sent to callback as per the API configuration """ - if code is None: - return { - "url": "https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&response_type=code&prompt=consent&client_id={}&include_granted_scopes=true&scope={}&redirect_uri={}".format(client_id, SCOPES, redirect_uri) - } - else: - google_contact = frappe.cache().hget("google_contacts", "google_contact") - frappe.db.set_value("Google Contacts", google_contact, "authorization_code", code) - frappe.db.commit() + google_contact = frappe.cache().hget("google_contacts", "google_contact") + frappe.db.set_value("Google Contacts", google_contact, "authorization_code", code) + frappe.db.commit() - authorize_access(google_contact) + authorize_access(google_contact) + +def get_google_contacts_object(g_contact): + """ + Returns an object of Google Calendar along with Google Calendar doc. + """ + google_settings = frappe.get_doc("Google Settings") + account = frappe.get_doc("Google Contacts", g_contact) + + credentials_dict = { + "token": account.get_access_token(), + "refresh_token": account.get_password(fieldname="refresh_token", raise_exception=False), + "token_uri": get_auth_url(), + "client_id": google_settings.client_id, + "client_secret": google_settings.get_password(fieldname="client_secret", raise_exception=False), + "scopes": "https://www.googleapis.com/auth/contacts" + } + + credentials = google.oauth2.credentials.Credentials(**credentials_dict) + google_contacts = googleapiclient.discovery.build("people", "v1", credentials=credentials) + + return google_contacts, account @frappe.whitelist() def sync(g_contact=None): @@ -108,58 +131,143 @@ def sync(g_contact=None): google_contacts = frappe.get_list("Google Contacts", filters=filters) - for google_contact in google_contacts: - doc = frappe.get_doc("Google Contacts", google_contact.name) - access_token = doc.get_access_token() + for g in google_contacts: + return sync_contacts_from_google_contacts(g.name) - headers = {"Authorization": "Bearer {}".format(access_token)} +def sync_contacts_from_google_contacts(g_contact): + """ + Syncs Contacts from Google Contacts. + https://developers.google.com/people/api/rest/v1/people.connections/list + """ + google_contacts, account = get_google_contacts_object(g_contact) + if not account.pull_from_google_contacts: + return + + results = [] + contacts_updated = 0 + + while True: try: - r = requests.get(REQUEST, headers=headers, params=PARAMS) - except Exception as e: - frappe.throw(e) + sync_token = account.get_password(fieldname="next_sync_token", raise_exception=False) or None + contacts = google_contacts.people().connections().list(resourceName='people/me',syncToken=sync_token, + personFields="names,emailAddresses,organizations,phoneNumbers").execute() + except HttpError as err: + frappe.throw(_("Google Contacts - Could not sync contacts from Google Contacts {0}, error code {1}.").format(account.name, err.resp.status)) - try: - r = r.json() - except Exception as e: - # if request doesn't return json show HTML ask permissions or to identify the error on google side - frappe.throw(e) + for contact in contacts.get("connections"): + results.append(contact) - connections = r.get("connections") - contacts_updated = 0 + if not contacts.get("nextPageToken"): + if contacts.get("nextSyncToken"): + frappe.db.set_value("Google Contacts", account.name, "next_sync_token", contacts.get("nextSyncToken")) + frappe.db.commit() + break - frappe.db.set_value("Google Contacts", doc.name, "last_sync_on", frappe.utils.now_datetime()) + frappe.db.set_value("Google Contacts", account.name, "last_sync_on", frappe.utils.now_datetime()) - if connections: - for idx, connection in enumerate(connections): - frappe.publish_realtime('import_google_contacts', dict(progress=idx+1, total=r.get("totalPeople")), user=frappe.session.user) + for idx, connection in enumerate(results): + frappe.publish_realtime('import_google_contacts', dict(progress=idx+1, total=len(results)), user=frappe.session.user) - for name in connection.get("names"): - if name.get("metadata").get("primary"): - contact = frappe.get_doc({ - "doctype": "Contact", - "salutation": name.get("honorificPrefix") or "", - "first_name": name.get("givenName") or "", - "middle_name": name.get("middleName") or "", - "last_name": name.get("familyName") or "", - "designation": get_indexed_value(connection.get("organizations"), 0, "title"), - "source": "Google Contacts", - "google_contacts_description": get_indexed_value(connection.get("organizations"), 0, "name") - }) + for name in connection.get("names"): + if name.get("metadata").get("primary"): + contacts_updated += 1 + contact = frappe.get_doc({ + "doctype": "Contact", + "first_name": name.get("givenName") or "", + "middle_name": name.get("middleName") or "", + "last_name": name.get("familyName") or "", + "designation": get_indexed_value(connection.get("organizations"), 0, "title"), + "pulled_from_google_contacts": 1, + "google_contacts": account.name, + "company_name": get_indexed_value(connection.get("organizations"), 0, "name") + }) - for email in connection.get("emailAddresses", []): - contact.add_email(email_id=email.get("value"), is_primary=1 if email.get("primary") else 0) + for email in connection.get("emailAddresses", []): + contact.add_email(email_id=email.get("value"), is_primary=1 if email.get("metadata").get("primary") else 0) - for phone in connection.get("phoneNumbers", []): - contact.add_phone(phone=phone.get("value"), is_primary=1 if phone.get("primary") else 0) + for phone in connection.get("phoneNumbers", []): + contact.add_phone(phone=phone.get("value"), is_primary=1 if phone.get("metadata").get("primary") else 0) - contact.insert(ignore_permissions=True) + contact.insert(ignore_permissions=True) - if g_contact: - return _("{0} Google Contacts synced.").format(contacts_updated) if contacts_updated > 0 else _("No new Google Contacts synced.") + return _("{0} Google Contacts synced.").format(contacts_updated) if contacts_updated > 0 \ + else _("No new Google Contacts synced.") - if g_contact: - return _("No Google Contacts present to sync.") # If no Google Contacts to sync +def insert_contacts_to_google_contacts(doc, method=None): + """ + Syncs Contacts from Google Contacts. + https://developers.google.com/people/api/rest/v1/people/createContact + """ + if not frappe.db.exists("Google Contacts", {"name": doc.google_contacts}) or doc.pulled_from_google_contacts \ + or not doc.sync_with_google_contacts: + return + + google_contacts, account = get_google_contacts_object(doc.google_contacts) + + if not account.push_to_google_contacts: + return + + names = { + "givenName": doc.first_name, + "middleName": doc.middle_name, + "familyName": doc.last_name + } + + phoneNumbers = [{"value": phone_no.phone} for phone_no in doc.phone_nos] + emailAddresses = [{"value": email_id.email_id} for email_id in doc.email_ids] + + try: + contact = google_contacts.people().createContact(parent='people/me', body={"names": [names],"phoneNumbers": phoneNumbers, + "emailAddresses": emailAddresses}).execute() + frappe.db.set_value("Contact", doc.name, "google_contacts_id", contact.get("resourceName")) + except HttpError as err: + frappe.msgprint(_("Google Calendar - Could not insert contact in Google Contacts {0}, error code {1}.").format(account.name, err.resp.status)) + +def update_contacts_to_google_contacts(doc, method=None): + """ + Syncs Contacts from Google Contacts. + https://developers.google.com/people/api/rest/v1/people/updateContact + """ + # Workaround to avoid triggering updation when Event is being inserted since + # creation and modified are same when inserting doc + if not frappe.db.exists("Google Contacts", {"name": doc.google_contacts}) or doc.is_new() \ + or not doc.sync_with_google_contacts: + return + + if doc.sync_with_google_contacts and not doc.google_contacts_id: + # If sync_with_google_contacts is checked later, then insert the contact rather than updating it. + insert_contacts_to_google_contacts(doc) + return + + google_contacts, account = get_google_contacts_object(doc.google_contacts) + + if not account.push_to_google_contacts: + return + + names = { + "givenName": doc.first_name, + "middleName": doc.middle_name, + "familyName": doc.last_name + } + + phoneNumbers = [{"value": phone_no.phone} for phone_no in doc.phone_nos] + emailAddresses = [{"value": email_id.email_id} for email_id in doc.email_ids] + + try: + contact = google_contacts.people().get(resourceName=doc.google_contacts_id, \ + personFields="names,emailAddresses,organizations,phoneNumbers").execute() + + contact["names"] = [names] + contact["phoneNumbers"] = phoneNumbers + contact["emailAddresses"] = emailAddresses + + google_contacts.people().updateContact(resourceName=doc.google_contacts_id,body={"names":[names], + "phoneNumbers":phoneNumbers,"emailAddresses":emailAddresses,"etag":contact.get("etag")}, + updatePersonFields="names,emailAddresses,organizations,phoneNumbers").execute() + frappe.msgprint(_("Contact Synced with Google Contacts.")) + except HttpError as err: + frappe.msgprint(_("Google Contacts - Could not update contact in Google Contacts {0}, error code {1}.").format(account.name, err.resp.status)) def get_indexed_value(d, index, key): if not d: