From d0cccc2e6160d54e3b5c1dcc9e2e9f77841f3fee Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Fri, 26 Sep 2025 20:04:10 +0530 Subject: [PATCH] feat: FB Lead Sync first cut --- crm/hooks.py | 2 + crm/lead_syncing/__init__.py | 0 crm/lead_syncing/doctype/__init__.py | 0 .../facebook_lead_form_question/__init__.py | 0 .../facebook_lead_form_question.json | 59 +++++++++ .../facebook_lead_form_question.py | 26 ++++ .../doctype/facebook_page/__init__.py | 0 .../doctype/facebook_page/facebook_page.js | 8 ++ .../doctype/facebook_page/facebook_page.json | 83 +++++++++++++ .../doctype/facebook_page/facebook_page.py | 24 ++++ .../facebook_page/test_facebook_page.py | 22 ++++ .../lead_form_field_mapping/__init__.py | 0 .../lead_form_field_mapping.json | 47 +++++++ .../lead_form_field_mapping.py | 9 ++ .../doctype/lead_sync_source/__init__.py | 0 .../lead_sync_source/lead_sync_source.js | 12 ++ .../lead_sync_source/lead_sync_source.json | 105 ++++++++++++++++ .../lead_sync_source/lead_sync_source.py | 116 ++++++++++++++++++ .../lead_sync_source/test_lead_sync_source.py | 22 ++++ crm/modules.txt | 3 +- pyproject.toml | 2 +- 21 files changed, 538 insertions(+), 2 deletions(-) create mode 100644 crm/lead_syncing/__init__.py create mode 100644 crm/lead_syncing/doctype/__init__.py create mode 100644 crm/lead_syncing/doctype/facebook_lead_form_question/__init__.py create mode 100644 crm/lead_syncing/doctype/facebook_lead_form_question/facebook_lead_form_question.json create mode 100644 crm/lead_syncing/doctype/facebook_lead_form_question/facebook_lead_form_question.py create mode 100644 crm/lead_syncing/doctype/facebook_page/__init__.py create mode 100644 crm/lead_syncing/doctype/facebook_page/facebook_page.js create mode 100644 crm/lead_syncing/doctype/facebook_page/facebook_page.json create mode 100644 crm/lead_syncing/doctype/facebook_page/facebook_page.py create mode 100644 crm/lead_syncing/doctype/facebook_page/test_facebook_page.py create mode 100644 crm/lead_syncing/doctype/lead_form_field_mapping/__init__.py create mode 100644 crm/lead_syncing/doctype/lead_form_field_mapping/lead_form_field_mapping.json create mode 100644 crm/lead_syncing/doctype/lead_form_field_mapping/lead_form_field_mapping.py create mode 100644 crm/lead_syncing/doctype/lead_sync_source/__init__.py create mode 100644 crm/lead_syncing/doctype/lead_sync_source/lead_sync_source.js create mode 100644 crm/lead_syncing/doctype/lead_sync_source/lead_sync_source.json create mode 100644 crm/lead_syncing/doctype/lead_sync_source/lead_sync_source.py create mode 100644 crm/lead_syncing/doctype/lead_sync_source/test_lead_sync_source.py diff --git a/crm/hooks.py b/crm/hooks.py index 3dcc2c4c..45f5f955 100644 --- a/crm/hooks.py +++ b/crm/hooks.py @@ -22,6 +22,8 @@ add_to_apps_screen = [ } ] +export_python_type_annotations = True + # Includes in # ------------------ diff --git a/crm/lead_syncing/__init__.py b/crm/lead_syncing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crm/lead_syncing/doctype/__init__.py b/crm/lead_syncing/doctype/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crm/lead_syncing/doctype/facebook_lead_form_question/__init__.py b/crm/lead_syncing/doctype/facebook_lead_form_question/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crm/lead_syncing/doctype/facebook_lead_form_question/facebook_lead_form_question.json b/crm/lead_syncing/doctype/facebook_lead_form_question/facebook_lead_form_question.json new file mode 100644 index 00000000..0db97c60 --- /dev/null +++ b/crm/lead_syncing/doctype/facebook_lead_form_question/facebook_lead_form_question.json @@ -0,0 +1,59 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-09-26 19:45:47.696180", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "label", + "id", + "column_break_hgde", + "key", + "type" + ], + "fields": [ + { + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label" + }, + { + "fieldname": "column_break_hgde", + "fieldtype": "Column Break" + }, + { + "fieldname": "key", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Key", + "reqd": 1 + }, + { + "fieldname": "type", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Type" + }, + { + "fieldname": "id", + "fieldtype": "Data", + "label": "ID" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-09-26 19:48:30.897092", + "modified_by": "hussain@frappe.io", + "module": "Lead Syncing", + "name": "Facebook Lead Form Question", + "owner": "hussain@frappe.io", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/lead_syncing/doctype/facebook_lead_form_question/facebook_lead_form_question.py b/crm/lead_syncing/doctype/facebook_lead_form_question/facebook_lead_form_question.py new file mode 100644 index 00000000..960e7c6d --- /dev/null +++ b/crm/lead_syncing/doctype/facebook_lead_form_question/facebook_lead_form_question.py @@ -0,0 +1,26 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class FacebookLeadFormQuestion(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 + + id: DF.Data | None + key: DF.Data + label: DF.Data | None + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + type: DF.Data | None + # end: auto-generated types + + pass diff --git a/crm/lead_syncing/doctype/facebook_page/__init__.py b/crm/lead_syncing/doctype/facebook_page/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crm/lead_syncing/doctype/facebook_page/facebook_page.js b/crm/lead_syncing/doctype/facebook_page/facebook_page.js new file mode 100644 index 00000000..36e9e671 --- /dev/null +++ b/crm/lead_syncing/doctype/facebook_page/facebook_page.js @@ -0,0 +1,8 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Facebook Page", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/lead_syncing/doctype/facebook_page/facebook_page.json b/crm/lead_syncing/doctype/facebook_page/facebook_page.json new file mode 100644 index 00000000..daf419ee --- /dev/null +++ b/crm/lead_syncing/doctype/facebook_page/facebook_page.json @@ -0,0 +1,83 @@ +{ + "actions": [], + "autoname": "field:id", + "creation": "2025-09-26 18:59:12.833879", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "page_name", + "account_id", + "category", + "column_break_eteo", + "id", + "access_token" + ], + "fields": [ + { + "fieldname": "category", + "fieldtype": "Data", + "label": "Category" + }, + { + "fieldname": "id", + "fieldtype": "Data", + "label": "ID", + "unique": 1 + }, + { + "fieldname": "account_id", + "fieldtype": "Data", + "label": "Account ID" + }, + { + "fieldname": "column_break_eteo", + "fieldtype": "Column Break" + }, + { + "fieldname": "access_token", + "fieldtype": "Small Text", + "label": "Access Token" + }, + { + "fieldname": "page_name", + "fieldtype": "Data", + "label": "Page Name" + } + ], + "grid_page_length": 50, + "in_create": 1, + "index_web_pages_for_search": 1, + "links": [ + { + "link_doctype": "Facebook Lead Form", + "link_fieldname": "page" + } + ], + "modified": "2025-09-26 19:36:59.413214", + "modified_by": "hussain@frappe.io", + "module": "Lead Syncing", + "name": "Facebook Page", + "naming_rule": "By fieldname", + "owner": "hussain@frappe.io", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "show_title_field_in_link": 1, + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "title_field": "page_name", + "track_changes": 1 +} diff --git a/crm/lead_syncing/doctype/facebook_page/facebook_page.py b/crm/lead_syncing/doctype/facebook_page/facebook_page.py new file mode 100644 index 00000000..5104c7e8 --- /dev/null +++ b/crm/lead_syncing/doctype/facebook_page/facebook_page.py @@ -0,0 +1,24 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class FacebookPage(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 + + access_token: DF.SmallText | None + account_id: DF.Data | None + category: DF.Data | None + id: DF.Data | None + page_name: DF.Data | None + # end: auto-generated types + + pass diff --git a/crm/lead_syncing/doctype/facebook_page/test_facebook_page.py b/crm/lead_syncing/doctype/facebook_page/test_facebook_page.py new file mode 100644 index 00000000..e854d3c4 --- /dev/null +++ b/crm/lead_syncing/doctype/facebook_page/test_facebook_page.py @@ -0,0 +1,22 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase + + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + + +class IntegrationTestFacebookPage(IntegrationTestCase): + """ + Integration tests for FacebookPage. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/crm/lead_syncing/doctype/lead_form_field_mapping/__init__.py b/crm/lead_syncing/doctype/lead_form_field_mapping/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crm/lead_syncing/doctype/lead_form_field_mapping/lead_form_field_mapping.json b/crm/lead_syncing/doctype/lead_form_field_mapping/lead_form_field_mapping.json new file mode 100644 index 00000000..a31a6f02 --- /dev/null +++ b/crm/lead_syncing/doctype/lead_form_field_mapping/lead_form_field_mapping.json @@ -0,0 +1,47 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-09-26 18:54:57.313880", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "source_field_name", + "column_break_zbml", + "crm_field_name" + ], + "fields": [ + { + "fieldname": "source_field_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Source Field Name", + "reqd": 1 + }, + { + "fieldname": "column_break_zbml", + "fieldtype": "Column Break" + }, + { + "fieldname": "crm_field_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "CRM Field Name", + "reqd": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-09-26 18:55:59.773584", + "modified_by": "hussain@frappe.io", + "module": "Lead Syncing", + "name": "Lead Form Field Mapping", + "owner": "hussain@frappe.io", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/crm/lead_syncing/doctype/lead_form_field_mapping/lead_form_field_mapping.py b/crm/lead_syncing/doctype/lead_form_field_mapping/lead_form_field_mapping.py new file mode 100644 index 00000000..3f9acc15 --- /dev/null +++ b/crm/lead_syncing/doctype/lead_form_field_mapping/lead_form_field_mapping.py @@ -0,0 +1,9 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class LeadFormFieldMapping(Document): + pass diff --git a/crm/lead_syncing/doctype/lead_sync_source/__init__.py b/crm/lead_syncing/doctype/lead_sync_source/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crm/lead_syncing/doctype/lead_sync_source/lead_sync_source.js b/crm/lead_syncing/doctype/lead_sync_source/lead_sync_source.js new file mode 100644 index 00000000..a07fc911 --- /dev/null +++ b/crm/lead_syncing/doctype/lead_sync_source/lead_sync_source.js @@ -0,0 +1,12 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Lead Sync Source", { + refresh(frm) { + frm.add_custom_button(__('Sync Now'), () => { + frm.call("sync_leads").then(() => { + frappe.msgprint(__('Lead sync initiated.')); + }); + }); + }, +}); diff --git a/crm/lead_syncing/doctype/lead_sync_source/lead_sync_source.json b/crm/lead_syncing/doctype/lead_sync_source/lead_sync_source.json new file mode 100644 index 00000000..47d9d25f --- /dev/null +++ b/crm/lead_syncing/doctype/lead_sync_source/lead_sync_source.json @@ -0,0 +1,105 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "autoincrement", + "creation": "2025-09-26 18:51:41.145560", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "type", + "access_token", + "column_break_lwcw", + "last_synced_at", + "section_break_itlk", + "field_mapping", + "facebook_tab", + "facebook_page", + "column_break_uxlr", + "facebook_lead_form" + ], + "fields": [ + { + "default": "Facebook", + "fieldname": "type", + "fieldtype": "Select", + "label": "Type", + "options": "Facebook" + }, + { + "fieldname": "column_break_lwcw", + "fieldtype": "Column Break" + }, + { + "fieldname": "last_synced_at", + "fieldtype": "Datetime", + "label": "Last Synced At", + "read_only": 1 + }, + { + "fieldname": "access_token", + "fieldtype": "Small Text", + "label": "Access Token" + }, + { + "fieldname": "section_break_itlk", + "fieldtype": "Section Break" + }, + { + "fieldname": "field_mapping", + "fieldtype": "Table", + "label": "Field Mapping", + "options": "Lead Form Field Mapping" + }, + { + "depends_on": "eval:doc.type===\"Facebook\"", + "fieldname": "facebook_tab", + "fieldtype": "Tab Break", + "label": "Facebook" + }, + { + "fieldname": "facebook_page", + "fieldtype": "Link", + "label": "Facebook Page", + "options": "Facebook Page" + }, + { + "fieldname": "column_break_uxlr", + "fieldtype": "Column Break" + }, + { + "fieldname": "facebook_lead_form", + "fieldtype": "Link", + "label": "Facebook Lead Form", + "options": "Facebook Lead Form" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-09-26 19:04:31.584094", + "modified_by": "hussain@frappe.io", + "module": "Lead Syncing", + "name": "Lead Sync Source", + "naming_rule": "Autoincrement", + "owner": "hussain@frappe.io", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "title_field": "type", + "track_changes": 1 +} diff --git a/crm/lead_syncing/doctype/lead_sync_source/lead_sync_source.py b/crm/lead_syncing/doctype/lead_sync_source/lead_sync_source.py new file mode 100644 index 00000000..e438aa56 --- /dev/null +++ b/crm/lead_syncing/doctype/lead_sync_source/lead_sync_source.py @@ -0,0 +1,116 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe.integrations.utils import make_get_request +from frappe.model.document import Document + +FB_GRAPH_API_BASE = "https://graph.facebook.com" +FB_GRAPH_API_VERSION = "v23.0" + + +def get_fb_graph_api_url(endpoint: str) -> str: + if endpoint.startswith("/"): + endpoint = endpoint[1:] + + return f"{FB_GRAPH_API_BASE}/{FB_GRAPH_API_VERSION}/{endpoint}" + + +class LeadSyncSource(Document): + def before_save(self): + if self.type == "Facebook" and self.access_token: + fetch_and_store_pages_from_facebook(self.access_token) + pass + + @frappe.whitelist() + def sync_leads(self): + if self.type == "Facebook" and self.access_token: + sync_leads_from_facebook(self.access_token, self.facebook_lead_form) + + +def sync_leads_from_facebook(access_token: str, lead_form_id: str) -> None: + url = get_fb_graph_api_url(f"/{lead_form_id}/leads") + leads = make_get_request( + url, + params={ + "access_token": access_token, + "fields": "id,created_time,field_data", + "limit": 15000, + }, + ).get("data", []) + for lead in leads: + frappe.get_doc( + { + "doctype": "CRM Lead", + "first_name": lead["field_data"][0]["values"][0], + "source": "Facebook", + } + ).insert(ignore_permissions=True) + + +def fetch_and_store_pages_from_facebook(access_token: str) -> None: + account_details = get_fb_account_details(access_token) + if not account_details.get("id"): + frappe.log_error("Invalid access token provided for Facebook.", "Lead Sync Source") + return + + url = get_fb_graph_api_url("/me/accounts") + pages = make_get_request(url, params={"access_token": access_token}).get("data", []) + for page in pages: + page_id = page["id"] + already_synced = frappe.db.exists("Facebook Page", page_id) + if not already_synced: + create_facebook_page_in_db(page, account_details) + fetch_and_store_leadgen_forms_from_facebook(page_id, page["access_token"]) + + +def get_fb_account_details(access_token: str) -> dict: + url = get_fb_graph_api_url("me") + return make_get_request(url, params={"access_token": access_token}) + + +def create_facebook_page_in_db(page: dict, account_details: dict) -> None: + frappe.get_doc( + { + "doctype": "Facebook Page", + "page_name": page["name"], + "id": page["id"], + "category": page["category"], + "access_token": page["access_token"], + "account_id": account_details["id"], + } + ).insert(ignore_permissions=True) + + +def fetch_and_store_leadgen_forms_from_facebook(page_id: str, page_access_token: str) -> None: + fields = "id,name,questions" + url = get_fb_graph_api_url(f"/{page_id}/leadgen_forms") + forms = make_get_request( + url, + params={ + "access_token": page_access_token, + "fields": fields, + "limit": 15000, + }, + ).get("data", []) + for form in forms: + form_id = form["id"] + already_synced = frappe.db.exists("Facebook Lead Form", form_id) + if already_synced: + continue + create_facebook_lead_form_in_db(form, page_id) + + +def create_facebook_lead_form_in_db(form: dict, page_id: str) -> None: + form_doc = frappe.get_doc( + { + "doctype": "Facebook Lead Form", + "form_name": form["name"], + "id": form["id"], + "page": page_id, + "questions": form["questions"], + } + ) + + frappe.errprint(form_doc.as_dict()) + form_doc.insert(ignore_permissions=True) diff --git a/crm/lead_syncing/doctype/lead_sync_source/test_lead_sync_source.py b/crm/lead_syncing/doctype/lead_sync_source/test_lead_sync_source.py new file mode 100644 index 00000000..ca9a99a3 --- /dev/null +++ b/crm/lead_syncing/doctype/lead_sync_source/test_lead_sync_source.py @@ -0,0 +1,22 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase + + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + + +class IntegrationTestLeadSyncSource(IntegrationTestCase): + """ + Integration tests for LeadSyncSource. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/crm/modules.txt b/crm/modules.txt index e2361914..5d6b4b0b 100644 --- a/crm/modules.txt +++ b/crm/modules.txt @@ -1 +1,2 @@ -FCRM \ No newline at end of file +FCRM +Lead Syncing \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e161909d..720b7b90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,4 +61,4 @@ typing-modules = ["frappe.types.DF"] [tool.ruff.format] quote-style = "double" indent-style = "tab" -docstring-code-format = true \ No newline at end of file +# docstring-code-format = true \ No newline at end of file