snapshot of https://github.com/frappe/crm.git (develop, 2026-09-18; blobs > 131072B omitted)
Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1m13s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 6s
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped
Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1m13s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 6s
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped
This commit is contained in:
0
crm/lead_syncing/__init__.py
Normal file
0
crm/lead_syncing/__init__.py
Normal file
37
crm/lead_syncing/background_sync.py
Normal file
37
crm/lead_syncing/background_sync.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def sync_leads_from_all_enabled_sources(frequency: str | None = None) -> None:
|
||||
enabled_sources = frappe.get_all(
|
||||
"Lead Sync Source", filters={"enabled": 1, "background_sync_frequency": frequency}, pluck="name"
|
||||
)
|
||||
for source in enabled_sources:
|
||||
lead_sync_source = frappe.get_cached_doc("Lead Sync Source", source)
|
||||
try:
|
||||
lead_sync_source._sync_leads()
|
||||
except Exception as _:
|
||||
frappe.log_error(f"Error syncing leads for source {source}")
|
||||
|
||||
|
||||
def sync_leads_from_sources_5_minutes() -> None:
|
||||
sync_leads_from_all_enabled_sources("Every 5 Minutes")
|
||||
|
||||
|
||||
def sync_leads_from_sources_10_minutes() -> None:
|
||||
sync_leads_from_all_enabled_sources("Every 10 Minutes")
|
||||
|
||||
|
||||
def sync_leads_from_sources_15_minutes() -> None:
|
||||
sync_leads_from_all_enabled_sources("Every 15 Minutes")
|
||||
|
||||
|
||||
def sync_leads_from_sources_hourly() -> None:
|
||||
sync_leads_from_all_enabled_sources("Hourly")
|
||||
|
||||
|
||||
def sync_leads_from_sources_daily() -> None:
|
||||
sync_leads_from_all_enabled_sources("Daily")
|
||||
|
||||
|
||||
def sync_leads_from_sources_monthly() -> None:
|
||||
sync_leads_from_all_enabled_sources("Monthly")
|
||||
0
crm/lead_syncing/doctype/__init__.py
Normal file
0
crm/lead_syncing/doctype/__init__.py
Normal 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 Lead Form", {
|
||||
refresh(frm) {
|
||||
//
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:id",
|
||||
"creation": "2025-09-26 19:01:48.325681",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"page",
|
||||
"id",
|
||||
"column_break_ahyo",
|
||||
"form_name",
|
||||
"section_break_iqhq",
|
||||
"questions"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "page",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Page",
|
||||
"options": "Facebook Page",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "id",
|
||||
"fieldtype": "Data",
|
||||
"label": "ID",
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ahyo",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "form_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Form Name"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_iqhq",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "questions",
|
||||
"fieldtype": "Table",
|
||||
"label": "Questions",
|
||||
"options": "Facebook Lead Form Question"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-23 12:45:22.196877",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Lead Syncing",
|
||||
"name": "Facebook Lead Form",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "form_name"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils.data import comma_and
|
||||
|
||||
|
||||
class FacebookLeadForm(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
|
||||
|
||||
from crm.lead_syncing.doctype.facebook_lead_form_question.facebook_lead_form_question import (
|
||||
FacebookLeadFormQuestion,
|
||||
)
|
||||
|
||||
form_name: DF.Data | None
|
||||
id: DF.Data | None
|
||||
page: DF.Link
|
||||
questions: DF.Table[FacebookLeadFormQuestion]
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
self.check_mandatory_crm_fields_mapped()
|
||||
|
||||
def check_mandatory_crm_fields_mapped(self):
|
||||
# right now only first name is mandatory
|
||||
# later this can be elaborated to use doctype meta
|
||||
mandatory_crm_lead_fields = [{"label": "First Name", "fieldname": "first_name"}]
|
||||
mandatory_crm_lead_fieldnames = set(f["fieldname"] for f in mandatory_crm_lead_fields)
|
||||
|
||||
if self.is_new():
|
||||
return
|
||||
|
||||
mapped_fields = set(q.mapped_to_crm_field for q in self.questions)
|
||||
not_mapped = list(mandatory_crm_lead_fieldnames.difference(mapped_fields))
|
||||
|
||||
if not_mapped:
|
||||
not_mapped_labels = [
|
||||
f["label"] for f in mandatory_crm_lead_fields if f["fieldname"] in not_mapped
|
||||
]
|
||||
formatted_fields_list = frappe.bold(comma_and(not_mapped_labels))
|
||||
frappe.throw(frappe._("Mandatory field(s) {0} must be mapped").format(formatted_fields_list))
|
||||
@@ -0,0 +1,20 @@
|
||||
# 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 IntegrationTestFacebookLeadForm(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for FacebookLeadForm.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"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",
|
||||
"mapped_to_crm_field"
|
||||
],
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"fieldname": "mapped_to_crm_field",
|
||||
"fieldtype": "Autocomplete",
|
||||
"in_list_view": 1,
|
||||
"label": "Mapped to CRM Field"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-23 12:44:57.712174",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Lead Syncing",
|
||||
"name": "Facebook Lead Form Question",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# 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
|
||||
mapped_to_crm_field: DF.Autocomplete | None
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
type: DF.Data | None
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
0
crm/lead_syncing/doctype/facebook_page/__init__.py
Normal file
0
crm/lead_syncing/doctype/facebook_page/__init__.py
Normal file
8
crm/lead_syncing/doctype/facebook_page/facebook_page.js
Normal file
8
crm/lead_syncing/doctype/facebook_page/facebook_page.js
Normal 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) {
|
||||
|
||||
// },
|
||||
// });
|
||||
95
crm/lead_syncing/doctype/facebook_page/facebook_page.json
Normal file
95
crm/lead_syncing/doctype/facebook_page/facebook_page.json
Normal file
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"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": "2026-02-23 12:45:31.760295",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Lead Syncing",
|
||||
"name": "Facebook Page",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales 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
|
||||
}
|
||||
24
crm/lead_syncing/doctype/facebook_page/facebook_page.py
Normal file
24
crm/lead_syncing/doctype/facebook_page/facebook_page.py
Normal 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
|
||||
20
crm/lead_syncing/doctype/facebook_page/test_facebook_page.py
Normal file
20
crm/lead_syncing/doctype/facebook_page/test_facebook_page.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# 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
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("Failed Lead Sync Log", {
|
||||
refresh(frm) {
|
||||
const btn = frm.add_custom_button(__("Retry Sync"), () => {
|
||||
frm
|
||||
.call({
|
||||
doc: frm.doc,
|
||||
method: "retry_sync",
|
||||
btn,
|
||||
})
|
||||
.then(({ message }) => {
|
||||
frappe.show_alert(
|
||||
__("Sync Successful, CRM Lead: {0}!", [
|
||||
frappe.utils.get_form_link("CRM Lead", message.name, true),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-10-19 17:29:10.261307",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"type",
|
||||
"column_break_dhay",
|
||||
"source",
|
||||
"section_break_fhot",
|
||||
"lead_data",
|
||||
"section_break_knec",
|
||||
"traceback"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"default": "Failure",
|
||||
"fieldname": "type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Type",
|
||||
"options": "Duplicate\nFailure\nSynced",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "lead_data",
|
||||
"fieldtype": "Code",
|
||||
"label": "Lead Data",
|
||||
"options": "JSON",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_dhay",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "source",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Source",
|
||||
"options": "Lead Sync Source",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_fhot",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_knec",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "traceback",
|
||||
"fieldtype": "Code",
|
||||
"label": "Traceback",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-23 12:45:12.218952",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Lead Syncing",
|
||||
"name": "Failed Lead Sync Log",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
from crm.lead_syncing.doctype.lead_sync_source.facebook import FacebookSyncSource
|
||||
|
||||
|
||||
class FailedLeadSyncLog(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
|
||||
|
||||
lead_data: DF.Code | None
|
||||
source: DF.Link | None
|
||||
traceback: DF.Code | None
|
||||
type: DF.Literal["Duplicate", "Failure", "Synced"]
|
||||
# end: auto-generated types
|
||||
|
||||
@frappe.whitelist()
|
||||
def retry_sync(self):
|
||||
if not self.source:
|
||||
frappe.throw(frappe._("Can't retry sync for this without source!"))
|
||||
|
||||
source = frappe.get_cached_doc("Lead Sync Source", self.source)
|
||||
if source.type != "Facebook":
|
||||
frappe.throw(frappe._("Not implemented yet!"))
|
||||
|
||||
crm_lead = FacebookSyncSource(
|
||||
source.get_password("access_token"), source.facebook_lead_form
|
||||
).sync_single_lead(frappe.parse_json(self.lead_data), raise_exception=True)
|
||||
|
||||
self.type = "Synced"
|
||||
self.save()
|
||||
return crm_lead
|
||||
@@ -0,0 +1,20 @@
|
||||
# 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 IntegrationTestFailedLeadSyncLog(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for FailedLeadSyncLog.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
226
crm/lead_syncing/doctype/lead_sync_source/facebook.py
Normal file
226
crm/lead_syncing/doctype/lead_sync_source/facebook.py
Normal file
@@ -0,0 +1,226 @@
|
||||
import frappe
|
||||
from frappe.exceptions import ValidationError
|
||||
from frappe.integrations.utils import make_get_request
|
||||
|
||||
FB_GRAPH_API_BASE = "https://graph.facebook.com"
|
||||
FB_GRAPH_API_VERSION = "v23.0"
|
||||
|
||||
|
||||
class DuplicateLeadError(ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
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 FacebookSyncSource:
|
||||
def __init__(
|
||||
self,
|
||||
access_token: str,
|
||||
form_id: str,
|
||||
source_name: str | None = None,
|
||||
):
|
||||
self.access_token = access_token
|
||||
self.form_id = form_id
|
||||
self.source_name = source_name
|
||||
self.form_questions_mapping = None
|
||||
|
||||
def get_api_url(self, endpoint: str) -> str:
|
||||
return get_fb_graph_api_url(endpoint)
|
||||
|
||||
def sync(self):
|
||||
leads = self.fetch_leads()
|
||||
for lead in leads:
|
||||
self.sync_single_lead(lead)
|
||||
self.update_last_synced_at()
|
||||
|
||||
def sync_single_lead(self, lead, raise_exception=False):
|
||||
question_to_field_map = self.get_form_questions_mapping()
|
||||
lead_data = {item["name"]: item["values"][0] for item in lead["field_data"]}
|
||||
crm_lead_data = {
|
||||
question_to_field_map.get(k): v for k, v in lead_data.items() if k in question_to_field_map
|
||||
}
|
||||
crm_lead_data["source"] = "Facebook"
|
||||
crm_lead_data["facebook_lead_id"] = lead["id"]
|
||||
crm_lead_data["facebook_form_id"] = self.form_id
|
||||
|
||||
try:
|
||||
self.validate_duplicate_lead(crm_lead_data, question_to_field_map)
|
||||
return frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
**crm_lead_data,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
except (frappe.UniqueValidationError, DuplicateLeadError):
|
||||
self.create_failure_log(lead, "Duplicate")
|
||||
if raise_exception:
|
||||
raise
|
||||
except Exception:
|
||||
self.create_failure_log(lead, traceback=frappe.get_traceback(with_context=True))
|
||||
if raise_exception:
|
||||
raise
|
||||
|
||||
def fetch_leads(self):
|
||||
url = self.get_api_url(f"/{self.form_id}/leads")
|
||||
params = {
|
||||
"access_token": self.access_token,
|
||||
"fields": "id,created_time,field_data",
|
||||
"limit": 100000, # TODO: pagination
|
||||
}
|
||||
|
||||
filtering = []
|
||||
if self.last_synced_at:
|
||||
timestamp = frappe.utils.data.get_timestamp(self.last_synced_at)
|
||||
filtering.append({"field": "time_created", "operator": "GREATER_THAN", "value": timestamp})
|
||||
params["filtering"] = frappe.as_json(filtering)
|
||||
|
||||
return make_get_request(
|
||||
url,
|
||||
params=params,
|
||||
).get("data", [])
|
||||
|
||||
def get_form_questions_mapping(self):
|
||||
if self.form_questions_mapping:
|
||||
return self.form_questions_mapping
|
||||
|
||||
form_questions = frappe.db.get_all(
|
||||
"Facebook Lead Form Question",
|
||||
filters={"parent": self.form_id},
|
||||
fields=["key", "mapped_to_crm_field"],
|
||||
)
|
||||
self.form_questions_mapping = {
|
||||
q["key"]: q["mapped_to_crm_field"] for q in form_questions if q["mapped_to_crm_field"]
|
||||
}
|
||||
|
||||
return self.form_questions_mapping
|
||||
|
||||
@property
|
||||
def last_synced_at(self):
|
||||
return frappe.db.get_value(
|
||||
"Lead Sync Source", self.source_name or {"facebook_lead_form": self.form_id}, "last_synced_at"
|
||||
)
|
||||
|
||||
def create_failure_log(
|
||||
self, lead_data: dict | None = None, type: str = "Failure", traceback: str | None = None
|
||||
):
|
||||
return frappe.get_doc(
|
||||
{
|
||||
"doctype": "Failed Lead Sync Log",
|
||||
"type": type,
|
||||
"lead_data": frappe.as_json(lead_data),
|
||||
"source": self.get_source_name(),
|
||||
"traceback": traceback,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
def update_last_synced_at(self):
|
||||
frappe.db.set_value(
|
||||
"Lead Sync Source",
|
||||
self.source_name or {"facebook_lead_form": self.form_id},
|
||||
"last_synced_at",
|
||||
frappe.utils.now(),
|
||||
)
|
||||
|
||||
def get_source_name(self):
|
||||
if self.source_name:
|
||||
return self.source_name
|
||||
|
||||
return frappe.db.get_value("Lead Sync Source", {"facebook_lead_form": self.form_id}, "name")
|
||||
|
||||
def validate_duplicate_lead(self, lead_data: dict, field_mapping: dict):
|
||||
validation_filters = {crm_field: lead_data[crm_field] for crm_field in field_mapping.values()}
|
||||
validation_filters["facebook_form_id"] = lead_data["facebook_form_id"] # only for this campaign
|
||||
if frappe.db.exists("CRM Lead", validation_filters):
|
||||
raise DuplicateLeadError
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def fetch_and_store_pages_from_facebook(access_token: str) -> list[dict]:
|
||||
if not access_token:
|
||||
frappe.throw(frappe._("Access token is required"))
|
||||
|
||||
account_details = get_fb_account_details(access_token)
|
||||
if not account_details.get("id"):
|
||||
frappe.throw(frappe._("Invalid access token provided for Facebook."))
|
||||
|
||||
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)
|
||||
forms = fetch_and_store_leadgen_forms_from_facebook(page_id, page["access_token"])
|
||||
page["forms"] = forms
|
||||
|
||||
return pages
|
||||
|
||||
|
||||
def get_fb_account_details(access_token: str) -> dict:
|
||||
url = get_fb_graph_api_url("me")
|
||||
try:
|
||||
response = make_get_request(url, params={"access_token": access_token})
|
||||
except Exception as _:
|
||||
frappe.throw(frappe._("Please check your access token"))
|
||||
return response
|
||||
|
||||
|
||||
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) -> list[dict]:
|
||||
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)
|
||||
|
||||
return forms
|
||||
|
||||
|
||||
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"],
|
||||
}
|
||||
)
|
||||
form_doc.insert(ignore_permissions=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_pages_with_forms() -> list[dict]:
|
||||
pages = frappe.db.get_all("Facebook Page", fields=["id", "name"])
|
||||
for page in pages:
|
||||
forms = frappe.db.get_all("Facebook Lead Form", filters={"page": page["id"]}, fields=["id", "name"])
|
||||
page["forms"] = forms
|
||||
return pages
|
||||
@@ -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."));
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
130
crm/lead_syncing/doctype/lead_sync_source/lead_sync_source.json
Normal file
130
crm/lead_syncing/doctype/lead_sync_source/lead_sync_source.json
Normal file
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "prompt",
|
||||
"creation": "2025-09-26 18:51:41.145560",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"type",
|
||||
"access_token",
|
||||
"column_break_lwcw",
|
||||
"last_synced_at",
|
||||
"enabled",
|
||||
"background_sync_frequency",
|
||||
"facebook_section",
|
||||
"facebook_page",
|
||||
"column_break_zukm",
|
||||
"facebook_lead_form"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"default": "Facebook",
|
||||
"fieldname": "type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Type",
|
||||
"options": "Facebook",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_lwcw",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "last_synced_at",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "Last Synced At",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "access_token",
|
||||
"fieldtype": "Password",
|
||||
"label": "Access Token",
|
||||
"length": 500,
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "facebook_page",
|
||||
"fieldtype": "Link",
|
||||
"label": "Facebook Page",
|
||||
"options": "Facebook Page"
|
||||
},
|
||||
{
|
||||
"fieldname": "facebook_lead_form",
|
||||
"fieldtype": "Link",
|
||||
"label": "Facebook Lead Form",
|
||||
"options": "Facebook Lead Form",
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "enabled",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enabled?"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.type===\"Facebook\"",
|
||||
"fieldname": "facebook_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Facebook"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_zukm",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "Hourly",
|
||||
"fieldname": "background_sync_frequency",
|
||||
"fieldtype": "Select",
|
||||
"label": "Background Sync Frequency",
|
||||
"options": "Every 5 Minutes\nEvery 10 Minutes\nEvery 15 Minutes\nHourly\nDaily\nMonthly",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [
|
||||
{
|
||||
"link_doctype": "Failed Lead Sync Log",
|
||||
"link_fieldname": "source"
|
||||
}
|
||||
],
|
||||
"modified": "2026-02-23 12:45:55.637376",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Lead Syncing",
|
||||
"name": "Lead Sync Source",
|
||||
"naming_rule": "Set by user",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
from crm.lead_syncing.doctype.lead_sync_source.facebook import (
|
||||
FacebookSyncSource,
|
||||
fetch_and_store_pages_from_facebook,
|
||||
)
|
||||
|
||||
|
||||
class LeadSyncSource(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.Password
|
||||
background_sync_frequency: DF.Literal[
|
||||
"Every 5 Minutes", "Every 10 Minutes", "Every 15 Minutes", "Hourly", "Daily", "Monthly"
|
||||
]
|
||||
enabled: DF.Check
|
||||
facebook_lead_form: DF.Link | None
|
||||
facebook_page: DF.Link | None
|
||||
last_synced_at: DF.Datetime | None
|
||||
type: DF.Literal["Facebook"]
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
self.validate_same_fb_form_active()
|
||||
|
||||
def validate_same_fb_form_active(self):
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
if not self.facebook_lead_form:
|
||||
return
|
||||
|
||||
already_active = frappe.db.exists(
|
||||
"Lead Sync Source",
|
||||
{"enabled": 1, "facebook_lead_form": self.facebook_lead_form, "name": ["!=", self.name]},
|
||||
)
|
||||
|
||||
if already_active:
|
||||
frappe.throw(frappe._("A lead sync source is already enabled for this Facebook Lead Form!"))
|
||||
|
||||
def before_insert(self):
|
||||
if self.type == "Facebook" and self.access_token:
|
||||
fetch_and_store_pages_from_facebook(self.access_token)
|
||||
# rest of the source types can be added here
|
||||
|
||||
@frappe.whitelist()
|
||||
def sync_leads(self):
|
||||
if frappe.conf.developer_mode:
|
||||
self._sync_leads()
|
||||
return
|
||||
|
||||
frappe.enqueue_doc(self.doctype, self.name, "_sync_leads", queue="long")
|
||||
|
||||
def _sync_leads(self):
|
||||
if self.type == "Facebook" and self.access_token:
|
||||
if not self.facebook_lead_form:
|
||||
frappe.throw(frappe._("Please select a lead gen form before syncing!"))
|
||||
|
||||
FacebookSyncSource(self.get_password("access_token"), self.facebook_lead_form).sync()
|
||||
@@ -0,0 +1,20 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user