feat: FB Lead Sync first cut

This commit is contained in:
Hussain Nagaria 2025-09-26 20:04:10 +05:30
parent b1b7c5d246
commit d0cccc2e61
21 changed files with 538 additions and 2 deletions

View File

@ -22,6 +22,8 @@ add_to_apps_screen = [
}
]
export_python_type_annotations = True
# Includes in <head>
# ------------------

View File

View File

View File

@ -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": []
}

View File

@ -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

View File

@ -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) {
// },
// });

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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": []
}

View File

@ -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

View File

@ -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.'));
});
});
},
});

View File

@ -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
}

View File

@ -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)

View File

@ -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

View File

@ -1 +1,2 @@
FCRM
FCRM
Lead Syncing

View File

@ -61,4 +61,4 @@ typing-modules = ["frappe.types.DF"]
[tool.ruff.format]
quote-style = "double"
indent-style = "tab"
docstring-code-format = true
# docstring-code-format = true