diff --git a/onelead/meta_lead/doctype/meta_ads/meta_ads.json b/onelead/meta_lead/doctype/meta_ads/meta_ads.json index c741eed..9692e23 100644 --- a/onelead/meta_lead/doctype/meta_ads/meta_ads.json +++ b/onelead/meta_lead/doctype/meta_ads/meta_ads.json @@ -60,7 +60,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2024-11-19 22:52:01.322993", + "modified": "2025-02-18 13:33:48.990232", "modified_by": "Administrator", "module": "Meta Lead", "name": "Meta Ads", @@ -80,6 +80,7 @@ "write": 1 } ], + "search_fields": "ads_name, campaign", "show_title_field_in_link": 1, "sort_field": "modified", "sort_order": "DESC", diff --git a/onelead/meta_lead/doctype/meta_ads_page_config/meta_ads_page_config.js b/onelead/meta_lead/doctype/meta_ads_page_config/meta_ads_page_config.js index 8655897..d7736f4 100644 --- a/onelead/meta_lead/doctype/meta_ads_page_config/meta_ads_page_config.js +++ b/onelead/meta_lead/doctype/meta_ads_page_config/meta_ads_page_config.js @@ -2,6 +2,17 @@ // For license information, please see license.txt frappe.ui.form.on("Meta Ads Page Config", { + setup: function (frm) { + frm.fields_dict['forms_list'].grid.get_field('meta_lead_form').get_query = function () { + let added_forms = frm.doc.forms_list.map(row => row.meta_lead_form).filter(f => f); + return { + filters: [ + ['Meta Lead Form', 'name', 'not in', added_forms] + ] + }; + }; + }, + onload: function (frm) { // Hide or show Assignee fields based on the checkbox value frm.toggle_display('assignee_doctype', frm.doc.lead_assign); @@ -51,8 +62,10 @@ frappe.ui.form.on("Meta Ads Page Config", { filters: { page: frm.doc.page, // Match the Page ID status: "ACTIVE", // Form should be active - campaign: ["is", "set"] // Ensure a campaign is assigned + // 1a. remove campaign in light of M:M relation of campaign and form + // campaign: ["is", "set"] // Ensure a campaign is assigned }, + // TODO: 1a. remove campaign in light of M:M relation of campaign and form fields: ["name", "form_name", "campaign"] }, btn: $('.primary-action'), @@ -65,6 +78,7 @@ frappe.ui.form.on("Meta Ads Page Config", { // Loop through the retrieved forms activeForms.forEach(form => { // Check if the campaign is active + // TODO: 1a. Remove the logic of campaign in light of M;M relation. frappe.call({ method: "frappe.client.get", args: { @@ -102,13 +116,37 @@ frappe.ui.form.on("Meta Ads Page Config", { }); }) }); + + + // Refresh query every time a row is added or removed + // frm.fields_dict['forms_list'].grid.grid_events.on('add', function () { + // frm.fields_dict['forms_list'].grid.get_field('meta_lead_form').get_query = function () { + // let added_forms = frm.doc.forms_list.map(row => row.meta_lead_form).filter(f => f); + // return { + // filters: [ + // ['Meta Lead Form', 'name', 'not in', added_forms] + // ] + // }; + // }; + // }); + + // frm.fields_dict['forms_list'].grid.grid_events.on('remove', function () { + // frm.fields_dict['forms_list'].grid.get_field('meta_lead_form').get_query = function () { + // let added_forms = frm.doc.forms_list.map(row => row.meta_lead_form).filter(f => f); + // return { + // filters: [ + // ['Meta Lead Form', 'name', 'not in', added_forms] + // ] + // }; + // }; + // }); } }); frappe.ui.form.on("Meta Campaign Form List", { quick_map: function (frm, cdt, cdn) { - console.log("Quick Map button clicked"); + // let formatting_function_options = '\n' let row = locals[cdt][cdn]; // mandatory fieds of lead_doctype for validation let mandatory_fields = []; @@ -125,264 +163,279 @@ frappe.ui.form.on("Meta Campaign Form List", { } - // Fetch DocType "Meta Lead Form" data, to populate mapping form. frappe.call({ - method: 'frappe.client.get', - args: { - doctype: 'Meta Lead Form', - name: row.meta_lead_form - }, - callback: function (r) { - if (r.message) { - const metaLeadForm = r.message; - const mappings = metaLeadForm.mapping || []; - - console.log("mapping...", mappings) - - // Dialog to display mappings - let d = new frappe.ui.Dialog({ - size: 'extra-large', - title: 'Quick Map Lead Fields', - fields: [ - { - label: 'Lead DocType Reference', - fieldname: 'lead_doctype', - fieldtype: 'Link', - options: 'DocType', - // Try to take form lead doc ref first, if not found then from parent lead doc ref. - default: metaLeadForm.lead_doctype_reference || frm.doc.lead_doctype_reference, - // reqd: 1, - change: function () { - let lead_doctype = d.get_value('lead_doctype'); - if (lead_doctype) { - // Fetch fields from the selected Lead DocType - frappe.call({ - method: 'frappe.client.get', - args: { - doctype: 'DocType', - name: lead_doctype + method: "onelead.utils.formatting_functions.get_function_names", + callback: function (response) { + formatting_function_options = response.message || []; + console.log('form_fun', formatting_function_options) + // Fetch DocType "Meta Lead Form" data, to populate mapping form. + frappe.call({ + method: 'frappe.client.get', + args: { + doctype: 'Meta Lead Form', + name: row.meta_lead_form + }, + callback: function (r) { + if (r.message) { + const metaLeadForm = r.message; + const mappings = metaLeadForm.mapping || []; + + // Dialog to display mappings + let d = new frappe.ui.Dialog({ + size: 'extra-large', + title: 'Quick Map Lead Fields', + fields: [ + { + label: 'Lead DocType Reference', + fieldname: 'lead_doctype', + fieldtype: 'Link', + options: 'DocType', + // Try to take form lead doc ref first, if not found then from parent lead doc ref. + default: metaLeadForm.lead_doctype_reference || frm.doc.lead_doctype_reference, + // reqd: 1, + change: function () { + let lead_doctype = d.get_value('lead_doctype'); + if (lead_doctype) { + // Fetch fields from the selected Lead DocType + frappe.call({ + method: 'frappe.client.get', + args: { + doctype: 'DocType', + name: lead_doctype + }, + callback: function (r) { + if (r.message) { + // Filter fields based on mappable field types (Data, Link, etc.) + let mappable_fields = r.message.fields + .filter(field => ['Data', 'Phone', 'Link', 'Select', 'Int', 'Date'].includes(field.fieldtype)) + .map(field => field.fieldname); + + // Get mandatory fields + // mandatory_fields = r.message.fields + // .filter(field => field.reqd) + // .map(field => field.fieldname); + + // Update lead_doctype_field options in the mapping table + d.fields_dict.mapping.grid.update_docfield_property( + 'lead_doctype_field', 'options', mappable_fields.join('\n') + ); + d.fields_dict.mapping.grid.refresh(); // Refresh to apply new options + } + } + }); + } + } + }, + { + fieldtype: 'Section Break', + label: 'Field Mappings' + }, + { + fieldname: 'mapping', + label: 'Mapping Table', + fieldtype: 'Table', + options: 'Meta Lead Form Mapping', // Fields Mapping DocType + cannot_add_rows: false, // Allow adding new rows + in_place_edit: false, // Allow inline editing + fields: [ + { + label: 'Meta Field', + fieldname: 'meta_field', + fieldtype: 'Data', + in_list_view: 1 }, - callback: function (r) { - if (r.message) { - // Filter fields based on mappable field types (Data, Link, etc.) - let mappable_fields = r.message.fields - .filter(field => ['Data', 'Phone', 'Link', 'Select', 'Int', 'Date'].includes(field.fieldtype)) - .map(field => field.fieldname); - - // Get mandatory fields - // mandatory_fields = r.message.fields - // .filter(field => field.reqd) - // .map(field => field.fieldname); - - // Update lead_doctype_field options in the mapping table - d.fields_dict.mapping.grid.update_docfield_property( - 'lead_doctype_field', 'options', mappable_fields.join('\n') - ); - d.fields_dict.mapping.grid.refresh(); // Refresh to apply new options - } + { + label: 'Lead DocType Field', + fieldname: 'lead_doctype_field', + fieldtype: 'Select', + in_list_view: 1 + }, + { + label: 'Default Value', + fieldname: 'default_value', + fieldtype: 'Data', + in_list_view: 1 + }, + { + label: 'Formatting Function', + fieldname: 'formatting_function', + fieldtype: 'Select', + in_list_view: 1, + options: formatting_function_options + }, + { + label: 'Function Parameters', + fieldname: 'function_parameters', + fieldtype: 'Code', } - }); - } - } - }, - { - fieldtype: 'Section Break', - label: 'Field Mappings' - }, - { - fieldname: 'mapping', - label: 'Mapping Table', - fieldtype: 'Table', - options: 'Meta Lead Form Mapping', // Fields Mapping DocType - fields: [ + ] + }, { - label: 'Meta Field', - fieldname: 'meta_field', - fieldtype: 'Data', - in_list_view: 1 + fieldtype: 'Section Break', + label: 'Lead Assign' }, { - label: 'Lead DocType Field', - fieldname: 'lead_doctype_field', - fieldtype: 'Select', - in_list_view: 1 + default: metaLeadForm.assignee_doctype || frm.doc.assignee_doctype || "User", + fieldname: "assignee_doctype", + fieldtype: "Link", + label: "Assignee Doctype", + options: "DocType" }, { - label: 'Default Value', - fieldname: 'default_value', - fieldtype: 'Data', - in_list_view: 1 + fieldname: "column_break_1", + fieldtype: "Column Break" }, { - label: 'Formatting Function', - fieldname: 'formatting_function', - fieldtype: 'Code', - in_list_view: 1 + default: metaLeadForm.assign_to || frm.doc.assign_to, + fieldname: "assign_to", + fieldtype: "Dynamic Link", + label: "Assign To", + options: "assignee_doctype" } - ] - }, - { - fieldtype: 'Section Break', - label: 'Lead Assign' - }, - { - default: metaLeadForm.assignee_doctype || frm.doc.assignee_doctype || "User", - fieldname: "assignee_doctype", - fieldtype: "Link", - label: "Assignee Doctype", - options: "DocType" - }, - { - fieldname: "column_break_1", - fieldtype: "Column Break" - }, - { - default: metaLeadForm.assign_to || frm.doc.assign_to, - fieldname: "assign_to", - fieldtype: "Dynamic Link", - label: "Assign To", - options: "assignee_doctype" - } - - ], - primary_action_label: 'Save Mappings', - primary_action: function (data) { - // Validation for mandatory fields in the mapping - let missing_fields = mandatory_fields.filter(mandatory_field => - !data.mapping.some(mapping => mapping.lead_doctype_field === mandatory_field) - ); - - if (missing_fields.length > 0) { - frappe.show_alert({ - message: __('Warning: The following mandatory fields are missing in the mapping table: ') + - missing_fields.join(', '), - indicator: 'orange' - }, 10); // Longer display of Warning as feature is WIP and don't want to block by Error. - - // Stop the save action - // return; - } - - // Validations for mapping table - let validation_failed = false; - - data.mapping.forEach(mapping => { - // Only validate rows where `lead_doctype_field` is populated - if (mapping.lead_doctype_field) { - if (!mapping.meta_field && !mapping.default_value) { + + ], + primary_action_label: 'Save Mappings', + primary_action: function (data) { + // Validation for mandatory fields in the mapping + let missing_fields = mandatory_fields.filter(mandatory_field => + !data.mapping.some(mapping => mapping.lead_doctype_field === mandatory_field) + ); + + if (missing_fields.length > 0) { + frappe.show_alert({ + message: __('Warning: The following mandatory fields are missing in the mapping table: ') + + missing_fields.join(', '), + indicator: 'orange' + }, 10); // Longer display of Warning as feature is WIP and don't want to block by Error. + + // Stop the save action + // return; + } + + // Validations for mapping table + let validation_failed = false; + + data.mapping.forEach(mapping => { + // Only validate rows where `lead_doctype_field` is populated + if (mapping.lead_doctype_field) { + if (!mapping.meta_field && !mapping.default_value) { + validation_failed = true; + frappe.msgprint(__('Each Lead DocType Field with a value must have either a Meta Field or a Default Value.')); + } + } + }); + + if (!data.assign_to) { validation_failed = true; - frappe.msgprint(__('Each Lead DocType Field with a value must have either a Meta Field or a Default Value.')); + frappe.msgprint(__('Please complete Lead Assign section')) } - } - }); - - if (!data.assign_to) { - validation_failed = true; - frappe.msgprint(__('Please complete Lead Assign section')) - } - - if (validation_failed) { - return; // Stop the save action if validation fails - } - - console.log("Mapped data before update:", data.mapping); - - metaLeadForm.assignee_doctype = data.assignee_doctype; - metaLeadForm.assign_to = data.assign_to; - - // metaLeadForm.mapping = data.mapping; - metaLeadForm.mapping = [] - - // Add each entry from `data.mapping` to `latest_meta_lead_form.mapping` - data.mapping.forEach(mapping_entry => { - // Create a new mapping row object - const child_row = { - meta_field: mapping_entry.meta_field, - lead_doctype_field: mapping_entry.lead_doctype_field, - default_value: mapping_entry.default_value, - formatting_function: mapping_entry.formatting_function - }; - - // Push the new row directly into the `mapping` array - metaLeadForm.mapping.push(child_row); - }); - metaLeadForm.lead_doctype_reference = data.lead_doctype; - - // Save the updated document - frappe.call({ - method: 'frappe.client.save', - args: { - doc: metaLeadForm - }, - callback: function (save_response) { - if (save_response.message) { - frappe.msgprint(__('Fields mapped and saved successfully.')); - row.status = 'Mapped'; - frm.refresh_field('forms_list'); + + if (validation_failed) { + return; // Stop the save action if validation fails } + + console.log("Mapped data before update:", data.mapping); + + metaLeadForm.assignee_doctype = data.assignee_doctype; + metaLeadForm.assign_to = data.assign_to; + + // metaLeadForm.mapping = data.mapping; + metaLeadForm.mapping = [] + + // Add each entry from `data.mapping` to `latest_meta_lead_form.mapping` + data.mapping.forEach(mapping_entry => { + // Create a new mapping row object + const child_row = { + meta_field: mapping_entry.meta_field, + lead_doctype_field: mapping_entry.lead_doctype_field, + default_value: mapping_entry.default_value, + formatting_function: mapping_entry.formatting_function, + function_parameters: mapping_entry.function_parameters + }; + + // Push the new row directly into the `mapping` array + metaLeadForm.mapping.push(child_row); + }); + metaLeadForm.lead_doctype_reference = data.lead_doctype; + + // Save the updated document + frappe.call({ + method: 'frappe.client.save', + args: { + doc: metaLeadForm + }, + callback: function (save_response) { + if (save_response.message) { + frappe.msgprint(__('Fields mapped and saved successfully.')); + row.status = 'Mapped'; + frm.refresh_field('forms_list'); + } + } + }); + + // Update mappings and lead doctype in "Meta Lead Form" + // frappe.call({ + // method: 'frappe.client.get', + // args: { + // doctype: 'Meta Lead Form', + // name: metaLeadForm.name // row.meta_lead_form + // }, + // callback: function (refetch_response) { + // if (refetch_response.message) { + // let latest_meta_lead_form = refetch_response.message; + + // console.log(data.mapping) + + // // Update only necessary fields + // latest_meta_lead_form.lead_doctype_reference = data.lead_doctype; + // latest_meta_lead_form.mapping = data.mapping; + + // // Save the updated document + // frappe.call({ + // method: 'frappe.client.save', + // args: { + // doc: latest_meta_lead_form + // }, + // callback: function (save_response) { + // if (save_response.message) { + // frappe.msgprint(__('Fields mapped and saved successfully.')); + // row.status = 'Mapped'; + // frm.refresh_field('forms_list'); + // } + // } + // }); + // } else { + // frappe.msgprint(__('Failed to fetch the latest document. Please try again.')); + // } + // } + // }); + + + d.hide(); } }); - - // Update mappings and lead doctype in "Meta Lead Form" - // frappe.call({ - // method: 'frappe.client.get', - // args: { - // doctype: 'Meta Lead Form', - // name: metaLeadForm.name // row.meta_lead_form - // }, - // callback: function (refetch_response) { - // if (refetch_response.message) { - // let latest_meta_lead_form = refetch_response.message; - - // console.log(data.mapping) - - // // Update only necessary fields - // latest_meta_lead_form.lead_doctype_reference = data.lead_doctype; - // latest_meta_lead_form.mapping = data.mapping; - - // // Save the updated document - // frappe.call({ - // method: 'frappe.client.save', - // args: { - // doc: latest_meta_lead_form - // }, - // callback: function (save_response) { - // if (save_response.message) { - // frappe.msgprint(__('Fields mapped and saved successfully.')); - // row.status = 'Mapped'; - // frm.refresh_field('forms_list'); - // } - // } - // }); - // } else { - // frappe.msgprint(__('Failed to fetch the latest document. Please try again.')); - // } - // } - // }); - - - d.hide(); + + console.log(d.fields_dict.mapping.grid.doctype) + // populate mapping table with existing data. + const mappingTable = d.fields_dict.mapping.grid.get_data(); + + console.log("Mapping Table", mappingTable) + + d.fields_dict.mapping.df.data = mappings.map(mapping => ({ + meta_field: mapping.meta_field, + lead_doctype_field: mapping.lead_doctype_field, + default_value: mapping.default_value, + formatting_function: mapping.formatting_function, + function_parameters: mapping.function_parameters + })); + + d.fields_dict.mapping.grid.refresh(); // Refresh the table in the dialog + d.show(); + } else { + frappe.msgprint(__('Meta Lead Form not found.')); } - }); - - console.log(d.fields_dict.mapping.grid.doctype) - // populate mapping table with existing data. - const mappingTable = d.fields_dict.mapping.grid.get_data(); - - console.log("Mapping Table", mappingTable) - - d.fields_dict.mapping.df.data = mappings.map(mapping => ({ - meta_field: mapping.meta_field, - lead_doctype_field: mapping.lead_doctype_field, - default_value: mapping.default_value, - formatting_function: mapping.formatting_function - })); - - d.fields_dict.mapping.grid.refresh(); // Refresh the table in the dialog - d.show(); - } else { - frappe.msgprint(__('Meta Lead Form not found.')); - } + } + }); } }); diff --git a/onelead/meta_lead/doctype/meta_campaign_form_list/meta_campaign_form_list.json b/onelead/meta_lead/doctype/meta_campaign_form_list/meta_campaign_form_list.json index d6b9399..8031518 100644 --- a/onelead/meta_lead/doctype/meta_campaign_form_list/meta_campaign_form_list.json +++ b/onelead/meta_lead/doctype/meta_campaign_form_list/meta_campaign_form_list.json @@ -27,6 +27,7 @@ "fetch_from": "meta_lead_form.form_name", "fieldname": "form_name", "fieldtype": "Read Only", + "in_list_view": 1, "in_preview": 1, "label": "Form Name" }, @@ -52,8 +53,6 @@ "fieldname": "campaign", "fieldtype": "Link", "in_filter": 1, - "in_list_view": 1, - "in_preview": 1, "in_standard_filter": 1, "label": "Campaign", "options": "Meta Campaign", @@ -63,7 +62,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-11-20 00:34:32.273912", + "modified": "2025-03-06 00:50:29.294400", "modified_by": "Administrator", "module": "Meta Lead", "name": "Meta Campaign Form List", diff --git a/onelead/meta_lead/doctype/meta_lead_form/meta_lead_form.js b/onelead/meta_lead/doctype/meta_lead_form/meta_lead_form.js index d6d2cbe..aa81602 100644 --- a/onelead/meta_lead/doctype/meta_lead_form/meta_lead_form.js +++ b/onelead/meta_lead/doctype/meta_lead_form/meta_lead_form.js @@ -67,7 +67,71 @@ frappe.ui.form.on("Meta Lead Form", { } } }); - }); + }) + .attr('title', __('Create a new Campaign document using fields from this form, If not, this will be created automatically when leads are received.')); + + } + + if (!frm.doc.ads) { + // ----- NEW BUTTON: Create Ads ----- + frm.add_custom_button(__('Create Ads'), function () { + // 1. Generate an Ads ID and Ads Name + const adsId = (frm.doc.form_name || "Ad") + "_" + (frm.doc.form_id || ""); + const adsName = frm.doc.form_name || `Ads for ${frm.doc.form_id}`; + + // 2. Validate necessary fields + if (!frm.doc.campaign) { + frappe.msgprint(__('Please link a Campaign first before creating Ads')); + return; + } + + // 3. Check if a Meta Ads doc with the derived Ads ID already exists + frappe.call({ + method: "frappe.client.get_list", + args: { + doctype: "Meta Ads", + filters: { ads_id: adsId }, + fields: ["name", "ads_name"] + }, + callback: function (r) { + if (r.message && r.message.length > 0) { + // Found an existing Ads doc + const existingAds = r.message[0]; + frappe.msgprint(__("Ads already exists: ") + existingAds.ads_name); + + // Link that Ads doc to this form (assuming you have a 'meta_ads' link field or similar) + frm.set_value("meta_ads", existingAds.name); + frm.save(); + } else { + // 4. Insert a new Meta Ads record + frappe.call({ + method: "frappe.client.insert", + args: { + doc: { + doctype: "Meta Ads", + ads_id: adsId, + ads_name: adsName, + status: frm.doc.status, + campaign: frm.doc.campaign, + has_lead_form: 1 // or whatever your checkbox field is named + } + }, + callback: function (res) { + if (!res.exc) { + const adsDoc = res.message; + frappe.msgprint(__("New Ads created: ") + adsDoc.ads_name); + + // Link the newly created Ads to this form + frm.set_value("meta_ads", adsDoc.name); + frm.save(); + } + } + }); + } + } + }); + }) + .attr('title', __('Create a new Meta Ads document using fields from this form, If not, this will be created automatically when leads are received.')); } } }); diff --git a/onelead/meta_lead/doctype/meta_lead_form/meta_lead_form.json b/onelead/meta_lead/doctype/meta_lead_form/meta_lead_form.json index d8ea74a..f371930 100644 --- a/onelead/meta_lead/doctype/meta_lead_form/meta_lead_form.json +++ b/onelead/meta_lead/doctype/meta_lead_form/meta_lead_form.json @@ -51,6 +51,7 @@ { "fieldname": "ads", "fieldtype": "Link", + "hidden": 1, "label": "Ads", "options": "Meta Ads" }, @@ -157,7 +158,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2024-12-28 18:42:11.538615", + "modified": "2025-02-12 23:34:35.747040", "modified_by": "Administrator", "module": "Meta Lead", "name": "Meta Lead Form", diff --git a/onelead/meta_lead/doctype/meta_lead_form/meta_lead_form.py b/onelead/meta_lead/doctype/meta_lead_form/meta_lead_form.py index e6f57e4..3e593cc 100644 --- a/onelead/meta_lead/doctype/meta_lead_form/meta_lead_form.py +++ b/onelead/meta_lead/doctype/meta_lead_form/meta_lead_form.py @@ -26,6 +26,7 @@ def on_update(self): After insert, if `assign_to` or `assignee_doctype` are changed and a campaign is linked, update the linked campaign with the new values. """ + # TODO: 1a - 1c: Remove the logic in light of the new M:M relationship between `Meta Lead Form` and `Meta Campaign` if self.campaign: # Fetch the linked campaign campaign = frappe.get_doc("Meta Campaign", self.campaign) diff --git a/onelead/meta_lead/doctype/meta_lead_form_mapping/meta_lead_form_mapping.json b/onelead/meta_lead/doctype/meta_lead_form_mapping/meta_lead_form_mapping.json index 183ff56..0b68401 100644 --- a/onelead/meta_lead/doctype/meta_lead_form_mapping/meta_lead_form_mapping.json +++ b/onelead/meta_lead/doctype/meta_lead_form_mapping/meta_lead_form_mapping.json @@ -9,7 +9,8 @@ "meta_field", "lead_doctype_field", "default_value", - "formatting_function" + "formatting_function", + "function_parameters" ], "fields": [ { @@ -41,14 +42,19 @@ "allow_in_quick_entry": 1, "description": "Add custom python function to process and format meta field value for Lead DOCTYPE entry.", "fieldname": "formatting_function", - "fieldtype": "Code", + "fieldtype": "Select", "label": "Formatting function" + }, + { + "fieldname": "function_parameters", + "fieldtype": "Code", + "label": "Function Parameters" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-11-13 01:56:28.812630", + "modified": "2025-03-06 01:21:19.404169", "modified_by": "Administrator", "module": "Meta Lead", "name": "Meta Lead Form Mapping", diff --git a/onelead/meta_lead/doctype/meta_lead_logs/__init__.py b/onelead/meta_lead/doctype/meta_lead_logs/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/onelead/meta_lead/doctype/meta_lead_logs/meta_lead_logs.js b/onelead/meta_lead/doctype/meta_lead_logs/meta_lead_logs.js deleted file mode 100644 index c57784f..0000000 --- a/onelead/meta_lead/doctype/meta_lead_logs/meta_lead_logs.js +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2024, Redsoftware Solutions and contributors -// For license information, please see license.txt - -// frappe.ui.form.on("Meta Lead Logs", { -// refresh(frm) { - -// }, -// }); diff --git a/onelead/meta_lead/doctype/meta_lead_logs/meta_lead_logs.json b/onelead/meta_lead/doctype/meta_lead_logs/meta_lead_logs.json deleted file mode 100644 index 2c495bc..0000000 --- a/onelead/meta_lead/doctype/meta_lead_logs/meta_lead_logs.json +++ /dev/null @@ -1,128 +0,0 @@ -{ - "actions": [], - "allow_rename": 1, - "autoname": "format:{page_id}_{leadgen_id}", - "creation": "2024-09-01 16:08:57.426625", - "doctype": "DocType", - "engine": "InnoDB", - "field_order": [ - "request_dump_section", - "json", - "detailed_log_section", - "page_id", - "leadgen_id", - "fetch_lead", - "meta_ads_config", - "lead_doctype_reference", - "column_break_vdeq", - "lead_entry_successful", - "error", - "lead_section", - "lead_json", - "lead_doctype" - ], - "fields": [ - { - "fieldname": "request_dump_section", - "fieldtype": "Section Break", - "label": "Webhook data dump" - }, - { - "fieldname": "json", - "fieldtype": "JSON", - "label": "json" - }, - { - "fieldname": "detailed_log_section", - "fieldtype": "Section Break", - "label": "Detailed Log" - }, - { - "fieldname": "page_id", - "fieldtype": "Data", - "label": "Page ID" - }, - { - "fieldname": "leadgen_id", - "fieldtype": "Data", - "label": "Leadgen ID" - }, - { - "fieldname": "fetch_lead", - "fieldtype": "Button", - "label": "fetch lead" - }, - { - "fieldname": "meta_ads_config", - "fieldtype": "Link", - "label": "Meta Ads Config", - "options": "Meta Ad Campaign Config" - }, - { - "fetch_from": "meta_ads_config.lead_doctype", - "fieldname": "lead_doctype_reference", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Lead Doctype Reference", - "options": "DocType" - }, - { - "fieldname": "lead_section", - "fieldtype": "Section Break", - "label": "Lead" - }, - { - "fieldname": "lead_json", - "fieldtype": "JSON", - "label": "Lead JSON" - }, - { - "fieldname": "lead_doctype", - "fieldtype": "Dynamic Link", - "in_list_view": 1, - "label": "Lead doctype", - "options": "lead_doctype_reference" - }, - { - "default": "0", - "fieldname": "lead_entry_successful", - "fieldtype": "Check", - "in_list_view": 1, - "label": "Lead Entry Successful" - }, - { - "fieldname": "column_break_vdeq", - "fieldtype": "Column Break" - }, - { - "fieldname": "error", - "fieldtype": "Text", - "label": "Error" - } - ], - "index_web_pages_for_search": 1, - "links": [], - "modified": "2024-10-22 22:35:33.525876", - "modified_by": "Administrator", - "module": "Meta Lead", - "name": "Meta Lead Logs", - "naming_rule": "Expression", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "sort_field": "modified", - "sort_order": "DESC", - "states": [] -} \ No newline at end of file diff --git a/onelead/meta_lead/doctype/meta_lead_logs/meta_lead_logs.py b/onelead/meta_lead/doctype/meta_lead_logs/meta_lead_logs.py deleted file mode 100644 index c0e0212..0000000 --- a/onelead/meta_lead/doctype/meta_lead_logs/meta_lead_logs.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) 2024, Redsoftware Solutions and contributors -# For license information, please see license.txt - -# import frappe -from frappe.model.document import Document - - -class MetaLeadLogs(Document): - pass diff --git a/onelead/meta_lead/doctype/meta_lead_logs/test_meta_lead_logs.py b/onelead/meta_lead/doctype/meta_lead_logs/test_meta_lead_logs.py deleted file mode 100644 index e2ea9ba..0000000 --- a/onelead/meta_lead/doctype/meta_lead_logs/test_meta_lead_logs.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) 2024, Redsoftware Solutions and Contributors -# See license.txt - -# import frappe -from frappe.tests.utils import FrappeTestCase - - -class TestMetaLeadLogs(FrappeTestCase): - pass diff --git a/onelead/meta_lead/doctype/meta_webhook_config/meta_webhook_config.json b/onelead/meta_lead/doctype/meta_webhook_config/meta_webhook_config.json index 1e3ffa7..335fa8b 100644 --- a/onelead/meta_lead/doctype/meta_webhook_config/meta_webhook_config.json +++ b/onelead/meta_lead/doctype/meta_webhook_config/meta_webhook_config.json @@ -13,12 +13,16 @@ "app_name", "is_enabled", "page_flow", + "lead_creator", "column_break_blmf", "user_access_token", "user_id", "token_expiry", "is_token_valid", - "connect_facebook" + "connect_facebook", + "enable_polling", + "polling_interval", + "last_polling_time" ], "fields": [ { @@ -98,6 +102,33 @@ "fieldtype": "Check", "label": "Is Token Valid", "read_only": 1 + }, + { + "default": "0", + "fieldname": "enable_polling", + "fieldtype": "Check", + "label": "Enable Polling" + }, + { + "depends_on": "eval:doc.enable_polling", + "description": "Input time in Hours. e.g. 3", + "fieldname": "polling_interval", + "fieldtype": "Int", + "label": "Polling Interval" + }, + { + "fieldname": "last_polling_time", + "fieldtype": "Datetime", + "label": "Last Polling Time", + "read_only": 1 + }, + { + "default": "Administrator", + "description": "All leads will be created via this user, ensure user has lead access.", + "fieldname": "lead_creator", + "fieldtype": "Link", + "label": "Lead creator", + "options": "User" } ], "index_web_pages_for_search": 1, @@ -108,7 +139,7 @@ "link_fieldname": "page" } ], - "modified": "2024-11-16 01:11:29.074410", + "modified": "2025-03-08 02:01:59.656838", "modified_by": "Administrator", "module": "Meta Lead", "name": "Meta Webhook Config", diff --git a/onelead/meta_lead/doctype/meta_webhook_lead_logs/meta_webhook_lead_logs.json b/onelead/meta_lead/doctype/meta_webhook_lead_logs/meta_webhook_lead_logs.json index 82e3203..0bf796d 100644 --- a/onelead/meta_lead/doctype/meta_webhook_lead_logs/meta_webhook_lead_logs.json +++ b/onelead/meta_lead/doctype/meta_webhook_lead_logs/meta_webhook_lead_logs.json @@ -1,25 +1,30 @@ { "actions": [], + "allow_import": 1, "allow_rename": 1, "creation": "2024-10-30 22:59:49.795808", "doctype": "DocType", "engine": "InnoDB", "field_order": [ - "webhook_data_section", + "lead_meta_data_section", "leadgen_id", "page_id", "ad_id", "form_id", "created_time", "received_time", + "source", "column_break_ufar", "processing_status", - "error_message", "config_not_enabled", + "error_message", + "platform", + "organic", "reference_section", "campaign", "config_reference", "config_doctype_name", + "ads", "column_break_elsw", "lead_form", "lead_doc_reference", @@ -30,15 +35,12 @@ "lead_payload" ], "fields": [ - { - "fieldname": "webhook_data_section", - "fieldtype": "Section Break", - "label": "Webhook Data" - }, { "fieldname": "leadgen_id", "fieldtype": "Data", - "label": "Leadgen ID" + "label": "Leadgen ID", + "reqd": 1, + "unique": 1 }, { "fieldname": "page_id", @@ -49,6 +51,8 @@ { "fieldname": "ad_id", "fieldtype": "Data", + "in_filter": 1, + "in_standard_filter": 1, "label": "Ad ID" }, { @@ -167,11 +171,41 @@ "in_standard_filter": 1, "label": "Lead Form", "options": "Meta Lead Form" + }, + { + "fieldname": "ads", + "fieldtype": "Link", + "label": "Ads", + "options": "Meta Ads" + }, + { + "fieldname": "lead_meta_data_section", + "fieldtype": "Section Break", + "label": "Lead Meta Data" + }, + { + "fieldname": "source", + "fieldtype": "Select", + "label": "Source", + "options": "Import\nWebhook\nPolling" + }, + { + "fieldname": "platform", + "fieldtype": "Select", + "label": "Platform", + "options": "\nFacebook\nInstagram" + }, + { + "default": "0", + "fieldname": "organic", + "fieldtype": "Check", + "label": "Organic" } ], + "hide_toolbar": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2024-12-28 22:22:26.966580", + "modified": "2025-03-06 00:03:22.825017", "modified_by": "Administrator", "module": "Meta Lead", "name": "Meta Webhook Lead Logs", diff --git a/onelead/meta_lead/doctype/meta_webhook_lead_logs/meta_webhook_lead_logs_list.js b/onelead/meta_lead/doctype/meta_webhook_lead_logs/meta_webhook_lead_logs_list.js new file mode 100644 index 0000000..f049f65 --- /dev/null +++ b/onelead/meta_lead/doctype/meta_webhook_lead_logs/meta_webhook_lead_logs_list.js @@ -0,0 +1,30 @@ +frappe.listview_settings["Meta Webhook Lead Logs"] = { + onload: function (listview) { + listview.page.add_inner_button(__("Process Leads"), function () { + // Get all checked (selected) items in the list + let selected_docs = listview.get_checked_items(); + if (!selected_docs.length) { + frappe.msgprint(__("Please select at least one record to process.")); + return; + } + + // Convert each item into just the name string + let docnames = selected_docs.map(doc => doc.name); + + // Call server-side method + frappe.call({ + method: "onelead.utils.meta.manage_leads.bulk_manual_retry_lead_processing", + args: { + docnames: docnames + }, + callback: function (r) { + if (!r.exc) { + frappe.msgprint(__("Processing initiated. Results will be logged in each document's timeline or error_message.")); + // Optionally, refresh the list or do something else + listview.refresh(); + } + } + }); + }); + } +}; diff --git a/onelead/utils/formatting_functions.py b/onelead/utils/formatting_functions.py new file mode 100644 index 0000000..030ecad --- /dev/null +++ b/onelead/utils/formatting_functions.py @@ -0,0 +1,99 @@ +import frappe +import re +from datetime import datetime +from frappe.utils import now +import phonenumbers + +# Define a global dictionary to store function names dynamically +FORMATTING_FUNCTIONS = {} + +@frappe.whitelist() +def get_function_names(): + """Return a list of available function names for the Select field.""" + return list(FORMATTING_FUNCTIONS.keys()) + +def register_function(name): + """Decorator to register a function in the global FORMATTING_FUNCTIONS dictionary.""" + def wrapper(func): + FORMATTING_FUNCTIONS[name] = func + return func + return wrapper + +@register_function("format_phone_number") +def format_phone_number(phone_number, default_region="IN"): + """ + Format phone numbers to 'country code - local number' format. + + Args: + phone_number (str): The raw phone number string. + default_region (str): Default region code if the country code is missing. + + Returns: + str: Formatted phone number with 'country code - local number'. + """ + # Preserve the leading `+` if present, but remove everything else + phone_number = phone_number.strip() # Remove surrounding whitespace + if phone_number.startswith("+"): + cleaned_number = re.sub(r'[^\d+]', '', phone_number) # Remove everything except digits & `+` + else: + cleaned_number = re.sub(r'\D', '', phone_number) # Remove all non-digit characters + + try: + # If number starts with '+', parse as an international number + if cleaned_number.startswith('+'): + parsed_number = phonenumbers.parse(cleaned_number, None) + else: + parsed_number = phonenumbers.parse(cleaned_number, default_region) # Assume local number + national_number = str(parsed_number.national_number) + if cleaned_number.startswith(str(parsed_number.country_code)): # Avoid adding duplicate country code + cleaned_number = national_number + parsed_number = phonenumbers.parse(cleaned_number, default_region) + + # Check if the number is valid + if not phonenumbers.is_possible_number(parsed_number): + return "Invalid number" + + # Get the country code and national number + country_code = parsed_number.country_code + national_number = parsed_number.national_number + + return f"+{country_code}-{national_number}" + + except phonenumbers.NumberParseException: + return "Invalid number" + +@register_function("calculate_age") +def calculate_age(dob_str, dob_format="%Y-%m-%d"): + """Calculate age given a date of birth string.""" + try: + dob = datetime.strptime(dob_str, dob_format) + today = datetime.today() + return today.year - dob.year - ((today.month, today.day) < (dob.month, dob.day)) + except ValueError: + return None + +@register_function("extract_country_from_address") +def extract_country_from_address(address): + """Extract country from an address string if the country is the last element.""" + parts = address.strip().split(',') + return parts[-1].strip() if len(parts) > 1 else None + +@register_function("add_prefix") +def add_prefix(value, prefix=""): + """Add a prefix to a value if it's not empty.""" + return f"{prefix}{value}" if value else value + +@register_function("capitalize_name") +def capitalize_name(name): + """Capitalize each part of a name.""" + return ' '.join([word.capitalize() for word in name.split()]) + +@register_function("current_date") +def current_date(): + """add current date""" + return now() + +# Dictionary to map function names to actual functions +# formatting_functions = { +# "format_phone_number": format_phone_number, +# } \ No newline at end of file diff --git a/onelead/utils/meta/formatting_functions.py b/onelead/utils/meta/formatting_functions.py deleted file mode 100644 index fef719a..0000000 --- a/onelead/utils/meta/formatting_functions.py +++ /dev/null @@ -1,65 +0,0 @@ -import re -from datetime import datetime -from frappe.utils import now -import phonenumbers - -def format_phone_number(phone_number, code="+91"): - """ - Format phone numbers to 'country code - local number' format. - Args: - phone_number (str): The raw phone number string. - default_region (str): Default region code if the country code is missing. - Returns: - str: Formatted phone number with 'country code - local number'. - """ - # Clean default_code by removing non-numeric characters - code = re.sub(r'[^\d]', '', code) - - # Remove all non-numeric characters except the plus sign in phone_number - cleaned_number = re.sub(r'[^\d+]', '', phone_number) - - # If the number starts without '+', assume it's missing the country code - if not cleaned_number.startswith('+'): - cleaned_number = f"+{code}{cleaned_number}" - - try: - # Parse the phone number - parsed_number = phonenumbers.parse(cleaned_number) - - # Get the country code and national number - country_code = parsed_number.country_code - national_number = parsed_number.national_number - - return f"+{country_code}-{national_number}" - except phonenumbers.NumberParseException: - return "Invalid number" - -def calculate_age(dob_str, dob_format="%Y-%m-%d"): - """Calculate age given a date of birth string.""" - try: - dob = datetime.strptime(dob_str, dob_format) - today = datetime.today() - return today.year - dob.year - ((today.month, today.day) < (dob.month, dob.day)) - except ValueError: - return None - -def extract_country_from_address(address): - """Extract country from an address string if the country is the last element.""" - parts = address.strip().split(',') - return parts[-1].strip() if len(parts) > 1 else None - -def add_prefix(value, prefix=""): - """Add a prefix to a value if it's not empty.""" - return f"{prefix}{value}" if value else value - -def capitalize_name(name): - """Capitalize each part of a name.""" - return ' '.join([word.capitalize() for word in name.split()]) - -def now(current_value): - """add current date""" - return now() -# Dictionary to map function names to actual functions -# formatting_functions = { -# "format_phone_number": format_phone_number, -# } \ No newline at end of file diff --git a/onelead/utils/meta/manage_ads.py b/onelead/utils/meta/manage_ads.py index 7ce28df..b0958aa 100644 --- a/onelead/utils/meta/manage_ads.py +++ b/onelead/utils/meta/manage_ads.py @@ -537,6 +537,7 @@ def fetch_forms_based_on_selection(campaign_id, ad_account_id, page_id, ad_id=No # "doctype": "Meta Lead Form", "form_id": form["id"], # "ads": form["ads_id"] #commented out, as there needs to be hook for checking Ads Link present before insert + # TODO: 1c. Remove campaign mapping, once the changes are tested without camapign id. "campaign": campaign_id }) form_doc.save(ignore_permissions=True) @@ -584,6 +585,7 @@ def create_meta_ads_page_config_doc(page_id, forms): for form in forms: form_id = form.get("form_id") status = form.get("status") + # TODO: 1c. Remove campaign mapping, once the changes are tested without camapign id. campaign = form.get("campaign", None) # created_at = form.get("created_at") @@ -593,6 +595,7 @@ def create_meta_ads_page_config_doc(page_id, forms): # print("check if it's value present::: ", form) # check if the form has campaign then only add to forms_list + # TODO: 1c. Remove campaign mapping, once the changes are tested without camapign id. if not campaign: continue @@ -677,6 +680,7 @@ def fetch_forms_based_on_page(page_id, campaign_to_form_dict=None): if campaign_to_form_dict and campaign_to_form_dict.get(form_id, None): # print("ADD Campaign:::, ", campaign_to_form_dict.get(form_id).get('id')) + # TODO: 1c. Remove campaign mapping, once the changes are tested without camapign id. form_doc_payload["campaign"] = campaign_to_form_dict.get(form_id).get('id') # print('added to form_ids', form_doc_payload) diff --git a/onelead/utils/meta/manage_leads.py b/onelead/utils/meta/manage_leads.py index 3985984..4bb6805 100644 --- a/onelead/utils/meta/manage_leads.py +++ b/onelead/utils/meta/manage_leads.py @@ -1,86 +1,327 @@ import frappe import json +from datetime import datetime from facebook_business.api import FacebookAdsApi from facebook_business.adobjects.lead import Lead -from . import formatting_functions +# from .. import formatting_functions +from ..formatting_functions import FORMATTING_FUNCTIONS +from ..meta_lead import get_lead_config # from your_meta_sdk_module import MetaAdsAPI + +# ================== Utility Functions ================== + +def ensure_campaign_exists(form_doc): + """Ensure a campaign exists for the given Meta Lead Form.""" + try: + # Generate campaign ID based on form name and form ID + campaign_id = f"{form_doc.form_name.replace(' ', '_')}_{form_doc.form_id}" + campaign_name = form_doc.form_name or f"Campaign for {form_doc.form_id}" + campaign_objective = "OUTCOME_LEADS" + + # Check if a campaign with the generated ID already exists + existing_campaign = frappe.db.exists("Meta Campaign", {"campaign_id": campaign_id}) + + if existing_campaign: + return existing_campaign # Return the existing campaign ID + + # Create a new campaign document + new_campaign = frappe.get_doc({ + "doctype": "Meta Campaign", + "campaign_id": campaign_id, + "campaign_name": campaign_name, + "campaign_objective": campaign_objective, + "status": "ACTIVE", + "has_lead_form": 1, + "self_created": 1, + "assignee_doctype": form_doc.assignee_doctype, + "assign_to": form_doc.assign_to + }) + + new_campaign.insert(ignore_permissions=True) + frappe.db.commit() + + return new_campaign.name # Return new campaign ID + + except Exception as e: + frappe.logger().error(f"Error ensuring campaign exists for form_id {form_doc.form_id}: {str(e)}") + return None + +def ensure_ads_exists(form_doc, doc, ads_id=None): + """Ensure an ads exists for the given Meta Lead Form.""" + try: + current_date = datetime.now().strftime("%d%m%Y") + # Generate Ads ID and Name + ads_id = ads_id or f"{form_doc.form_name.replace(' ', '_')}_{form_doc.form_id}" + ads_name = f"{form_doc.form_name.replace(' ', '_').replace('-', '_')}_{current_date}" if form_doc.form_name else f"Ads_for_{form_doc.form_id}_{current_date}" + + # Check if an Ads with this ID exists + existing_ads = frappe.db.exists("Meta Ads", {"ads_id": ads_id}) + + if existing_ads: + existing_ads_doc = frappe.get_doc("Meta Ads", existing_ads) + # if existing_ads_doc.campaign: + # # TODO: 1a. remove this under form M:M campaign deps. + # form_doc.db_set("campaign", existing_ads_doc.campaign) + # # 1a.remove form_doc.campaign condition. + # elif form_doc.campaign and not existing_ads_doc.campaign: + # existing_ads_doc.db_set("campaign", form_doc.campaign) + # elif not existing_ads_doc.campaign and not form_doc.campaign: + if not existing_ads_doc.campaign: + campaign_id = ensure_campaign_exists(form_doc) + if not campaign_id: + frappe.throw(f"Could not create or find a campaign for form_id: {form_doc.form_id}") + # 1a. remove form_doc.camapgin under form M:M campaign deps. + # form_doc.db_set("campaign", campaign_id) + doc.db_set("campaign", campaign_id) + existing_ads_doc.db_set("campaign", campaign_id) + return existing_ads # Return the existing Ads ID + + # If the campaign is missing, create it first + # TODO: 1a. remove form_doc.campaign condition, directly make sure that campaign exists and assign it to ads. + # if not form_doc.campaign: + campaign_id = ensure_campaign_exists(form_doc) + doc.db_set("campaign", campaign_id) + # if campaign_id: + # form_doc.db_set("campaign", campaign_id) + # else: + if not campaign_id: + frappe.throw(f"Could not create or find a campaign for form_id: {form_doc.form_id}") + + # Create a new Meta Ads document + new_ads = frappe.get_doc({ + "doctype": "Meta Ads", + "ads_id": ads_id, + "ads_name": ads_name, + "status": form_doc.status if form_doc.status else "PAUSED", + # "campaign": form_doc.campaign or campaign_id, + "campaign": campaign_id, + "has_lead_form": 1 + }) + + new_ads.insert(ignore_permissions=True) + frappe.db.commit() + + return new_ads.name # Return new Ads ID + + except Exception as e: + frappe.logger().error(f"Error ensuring Ads exists for form_id {form_doc.form_id}: {str(e)}") + return None + + @frappe.whitelist() -def manual_retry_lead_processing(docname): +def bulk_manual_retry_lead_processing(docnames): + """ + Enqueue a background job to process multiple lead logs in bulk. + """ + if isinstance(docnames, str): + docnames = json.loads(docnames) + + # Enqueue the worker job + frappe.enqueue( + "onelead.utils.meta.manage_leads._process_lead_logs_in_bulk", + docnames=docnames, + queue='long' + ) + return {"status": "queued"} + +def _process_lead_logs_in_bulk(docnames): + """ + Actual worker function that processes each docname in the background. + """ + for docname in docnames: + try: + doc = frappe.get_doc("Meta Webhook Lead Logs", docname) + # Re-run your manual retry function or call the logic directly + manual_retry_lead_processing(docname) + except Exception as e: + frappe.logger().error(f"Bulk job error for doc {docname}: {str(e)}", exc_info=True) + +@frappe.whitelist() +def manual_retry_lead_processing(docname=None, doc=None): """Manually retry processing a lead log entry.""" try: - doc = frappe.get_doc("Meta Webhook Lead Logs", docname) + if doc and not isinstance(doc, frappe.model.document.Document): + if isinstance(doc, dict): + # Possibly convert to a Document via frappe._dict or re-fetch from DB + doc = frappe.get_doc("Meta Webhook Lead Logs", doc.get("name")) + + if not doc and docname: + doc = frappe.get_doc("Meta Webhook Lead Logs", docname) + if not doc: + frappe.throw("No valid doc or docname provided for manual retry.") + + if doc.processing_status in ["Processed", "Pending"]: + return {"status": "success", "message": "Lead already processed or is pending."} + + # If the doc is "Unconfigured" or missing key fields (like config_reference or lead_doctype), + # attempt to re-derive them from current Meta Webhook Config settings. + if doc.processing_status in ["Unconfigured", "Disabled"] or not doc.config_reference or not doc.lead_doctype: + reconfigure_lead_log(doc) + + # After reconfiguration attempt, process the lead return process_logged_lead(doc, "manual") except Exception as e: frappe.logger().error(f"Error in manual retry for lead log {docname}: {str(e)}", exc_info=True) return {"status": "error", "message": str(e)} +def reconfigure_lead_log(doc): + """ + Re-derive the 'config_reference', 'lead_doctype', etc. if missing. + Similar logic to create_lead_log but applied to an existing log doc. + """ + try: + # Re-fetch global config (to check if page_flow is enabled, or to locate correct doctype) + global_conf = frappe.get_single("Meta Webhook Config") + + # 1. Check if the form exists in "Meta Lead Form" + configured_form = frappe.db.exists("Meta Lead Form", {"form_id": doc.form_id}) + + # 2. Attempt to find the appropriate config using existing 'page_id' and 'form_id' + config = get_lead_config(doc.page_id, doc.form_id, global_conf) + + # Decide which doctype name we expect: + doctype_name = "Meta Ads Page Config" if global_conf.page_flow else "Meta Ads Webhook Config" + doc.db_set("config_doctype_name", doctype_name) + + if configured_form: + form_doc = frappe.get_doc("Meta Lead Form", {"form_id": doc.form_id}) + doc.db_set("lead_form", doc.form_id) + # If the 'lead_doctype_reference' is defined in that Meta Lead Form + if form_doc.lead_doctype_reference: + doc.db_set("lead_doctype", form_doc.lead_doctype_reference) + else: + doc.db_set({ + "processing_status": "Unconfigured", + "error_message": f"No lead_doctype_reference found in 'Meta Lead Form' for form_id: {doc.form_id}" + }) + else: + doc.db_set({ + "processing_status": "Unconfigured", + "error_message": f"No form found in `Meta Lead Form` for form_id: {doc.form_id}, please fetch forms again to get the latest forms." + }) + + # If we found a matching config doc, set config_reference, campaign, etc. + if config: + doc.db_set("config_reference", config.name) + if not config.enable: + doc.db_set("config_not_enabled", 1) + + # If Campaign is set Globally in the config, set it in the log doc + if hasattr(config, "campaign") and config.campaign: + doc.db_set("campaign", config.campaign) + else: + # If no config is found, mark it as Unconfigured + doc.db_set({ + "processing_status": "Unconfigured", + "error_message": f"No configuration found for page_id: {doc.page_id} and form_id: {doc.form_id} in '{doctype_name}'" + }) + + # set the doc back to "Pending" to let the process_logged_lead handle it + # doc.db_set("processing_status", "Pending") + + except Exception as e: + frappe.logger().error(f"Error in reconfiguring lead log {doc.name}: {str(e)}", exc_info=True) + doc.db_set({ + "processing_status": "Error", + "error_message": f"Error in reconfigure_lead_log: {str(e)}" + }) + def process_logged_lead(doc, method): """Process a lead after it's logged in Meta Webhook Lead Logs.""" try: + meta_config = frappe.get_single("Meta Webhook Config") + + # FETCH LEAD DATA FROM META API + lead_data = None + if not doc.lead_payload: + # Use Meta SDK to fetch lead data + lead_data = fetch_lead_from_meta(doc.leadgen_id, meta_config) + if lead_data: + # Log the data first + doc.db_set({ + "lead_payload": json.dumps(lead_data), + "organic": lead_data.get("is_organic", False), + "platform": 'Instagram' if lead_data.get("platform") == 'ig' else 'Facebook' if lead_data.get("platform") == 'fb' else '', + }) + else: + lead_data = json.loads(doc.lead_payload) + + # Retrieve the form configuration for the given form_id form_config = frappe.get_doc("Meta Lead Form", {"form_id": doc.form_id}) # If form configuration is not found, update log status and exit - already configured. just setting up error. - if not form_config: - doc.db_set({ - "processing_status": "Unconfigured", - "error_message": f"No configuration found for form_id: {doc.form_id}" - }) - return + # TODO: 1b. remove this condition, as it's already handled in reconfigure_lead_log, and create_lead_log. + # if not form_config: + # doc.db_set("processing_status", "Unconfigured") + # doc.db_set("error_message", f"No form found in `Meta Lead Form` for form_id: {doc.form_id}, please fetch forms again to get the latest forms.") + # return + if not doc.campaign and doc.ad_id: + campaign_id = ensure_campaign_exists(form_config) + if campaign_id: + doc.db_set("campaign", campaign_id) + + # Ensure ads exists and update doc.ads if necessary + if not doc.ads and doc.ad_id: + ads_id = ensure_ads_exists(form_config, doc, doc.ad_id) + if ads_id: + # 1a. remove form_config.campaign for M:M relationship + # form_config.db_set("ads", ads_id) + doc.db_set("ads", ads_id) + # if not doc.camapign: + - meta_config = frappe.get_single("Meta Webhook Config") if meta_config.page_flow: - if not doc.lead_form: - doc.db_set('lead_form', form_config.name) if doc.config_not_enabled: doc.db_set({ "processing_status": "Disabled", - "error_message": f"Configuration {doc.config_reference} is not Enabled" - }) + "error_message": f"Configuration can be found, but {doc.config_reference} is not Enabled" + }) return + + # TODO: 1b. remove this condition, as it's already handled in reconfigure_lead_log, and create_lead_log. + # Rquired last check, if form_config is not found, then exit. if not doc.config_reference: doc.db_set({ - "processing_status": "Unconfigured", - "error_message": "Configuration Reference is not set" - }) + "processing_status": "Unconfigured", + "error_message": f"Configuration is not mapped properly, please make sure that form with form_id {doc.form_id} is mapped to a config of page {doc.page_id}" + }) return + # This is required for adding Lead link to the log doc. so checks to make sure it exists. if not form_config.lead_doctype_reference: - # update in dictionary form doc.db_set({ - "processing_status": "Unconfigured", - "error_message": "Lead Doc is not in form list or Configuration" - }) + "processing_status": "Unconfigured", + "error_message": f"Lead Doctype reference is not set properly in form with form_id {doc.form_id}" + }) return - if form_config.campaign: - doc.db_set("campaign", form_config.campaign) - else: - try: - ads_doc = frappe.get_doc("Meta Ads", doc.ad_id) - form_config.db_set("campaign", ads_doc.campaign) - doc.db_set("campaign", ads_doc.campaign) - except Exception as e: - frappe.logger().error(f"Error in setting campaign for leadgen_id {doc.leadgen_id}") - doc.db_set({ - "processing_status": "Disabled", - "error_message": f"Error in setting campaign for leadgen_id {doc.leadgen_id}" - }) - return - - # Use Meta SDK to fetch lead data - lead_data = fetch_lead_from_meta(doc.leadgen_id, meta_config) + if not doc.ads or not doc.campaign and doc.ad_id: + doc.db_set({ + "processing_status": "Unconfigured", + "error_message": "Ads and Campaign is not set in log doc" + }) + return + # else: + # try: + # ads_doc = frappe.get_doc("Meta Ads", doc.ad_id) + # form_config.db_set("campaign", ads_doc.campaign) + # doc.db_set("campaign", ads_doc.campaign) + # except Exception as e: + # frappe.logger().error(f"Error in setting campaign for leadgen_id {doc.leadgen_id}") + # doc.db_set("processing_status", "Disabled") + # doc.db_set("error_message", f"Error in setting campaign for leadgen_id {doc.leadgen_id}") + # return if lead_data: - # Log the data first - doc.db_set("lead_payload", json.dumps(lead_data)) # Map and create lead Entry - lead_doc = create_lead_entry(lead_data, form_config, doc) + user = meta_config.lead_creator + lead_doc = create_lead_entry(lead_data, form_config, doc, user) doc.db_set({ "processing_status": "Processed", "lead_doc_reference": lead_doc.name, "error_message": "" - }) + }) else: doc.db_set({ "processing_status": "Error", @@ -117,9 +358,11 @@ def fetch_lead_from_meta(leadgen_id, meta_config): # Initialize the Facebook API FacebookAdsApi.init(app_id, app_secret, user_token) - lead = Lead(leadgen_id).api_get() + lead = Lead(leadgen_id).api_get(fields=["ad_id", "campaign_id", "field_data", "form_id", "created_time", "is_organic", "platform", "post", "vehicle"]) + # convert lead to dictionary/ json lead = lead.export_all_data() + return lead except Exception as e: @@ -136,19 +379,23 @@ def process_default_value(default_value, log_doc, form_doc): # Extract the field name after "field:" prefix default_field_name = default_value.split("field:")[1].strip() - # Retrieve the field value from ads_config_doc - if hasattr(form_doc, default_field_name): - field_value = getattr(form_doc, default_field_name) - return field_value - elif hasattr(ads_config_doc, default_field_name): - field_value = getattr(ads_config_doc, default_field_name) - return field_value - else: - frappe.logger().warning(f"Field {default_field_name} not found in {log_doc.config_doctype_name} and Meta Lead Form") - return default_value + # Retrieve the field value from any of the below documents + # Priority order: log_doc → form_doc → ads_config_doc + for source in (log_doc, form_doc, ads_config_doc): + if hasattr(source, default_field_name): + field_value = getattr(source, default_field_name) + + # If the field exists but is None or empty, continue checking the next source + if field_value not in [None, ""]: + return field_value + + # If not found in any document, log a warning and return the original default_value + frappe.logger().warning( + f"Field '{default_field_name}' not found or is empty in {log_doc.config_doctype_name}, Meta Lead Form, and Ads Config." + ) return default_value -def create_lead_entry(lead_data, form_doc, log_doc): +def create_lead_entry(lead_data, form_doc, log_doc, user="Administrator"): """Create a new Lead record in Frappe based on Meta lead data and form configuration.""" try: field_data = lead_data.get("field_data", []) @@ -171,12 +418,19 @@ def create_lead_entry(lead_data, form_doc, log_doc): if mapping.formatting_function: try: # Split by comma to get function name and arguments - func_name, *args = [arg.strip() for arg in mapping.formatting_function.split(',')] - formatting_func = getattr(formatting_functions, func_name, None) + func_name = mapping.formatting_function + func_params = parse_function_parameters(mapping.function_parameters) + # func_params = mapping.function_parameters.split(',') if mapping.function_parameters else [] + # func_name, *args = [arg.strip() for arg in mapping.formatting_function.split(',')] + # formatting_func = getattr(formatting_functions, func_name, None) # Call the function with field_value and additional arguments - if formatting_func: - field_value = formatting_func(field_value, *args) + # if formatting_func: + # field_value = formatting_func(field_value, *args) + + if func_name in FORMATTING_FUNCTIONS: + formatting_func = FORMATTING_FUNCTIONS[func_name] + field_value = formatting_func(field_value, *func_params) except Exception as e: frappe.logger().error(f"Error in formatting function '{mapping.formatting_function}' for {lead_field}: {str(e)}") @@ -184,7 +438,7 @@ def create_lead_entry(lead_data, form_doc, log_doc): new_lead.set(lead_field, field_value) # Insert the new lead and commit to database - frappe.set_user("info@hairfreehairgrow.com") + frappe.set_user(user) res = new_lead.insert(ignore_permissions=True) frappe.db.commit() @@ -196,18 +450,108 @@ def create_lead_entry(lead_data, form_doc, log_doc): raise +def parse_function_parameters(param_string): + if not param_string: + return [] + + param_string = param_string.strip() + + # Try JSON parsing first (for both objects & lists) + try: + parsed_data = json.loads(param_string) + if isinstance(parsed_data, dict): # JSON Object (Key-Value) + return parsed_data + elif isinstance(parsed_data, list): # JSON List (Array) + return parsed_data + except json.JSONDecodeError: + pass # Not JSON, proceed with comma-separated parsing + + # Fallback: Comma-Separated String + return [param.strip() for param in param_string.split(',') if param.strip()] + + # Example setup for calling the function dynamically -def call_function_dynamically(func, value, *args): - # Check the function's parameter count - func_param_count = func.__code__.co_argcount - - # Call with only the required number of arguments - if func_param_count == 1: - # Function only expects one argument (e.g., value) - return func(value) - elif func_param_count == 2: - # Function expects two arguments (e.g., value, code) - return func(value, args[0] if args else None) - else: - # Function expects more than two arguments - return func(value, *args[:func_param_count - 1]) \ No newline at end of file +# def call_function_dynamically(func, value, *args): +# # Check the function's parameter count +# func_param_count = func.__code__.co_argcount + +# # Call with only the required number of arguments +# if func_param_count == 1: +# # Function only expects one argument (e.g., value) +# return func(value) +# elif func_param_count == 2: +# # Function expects two arguments (e.g., value, code) +# return func(value, args[0] if args else None) +# else: +# # Function expects more than two arguments +# return func(value, *args[:func_param_count - 1]) + + + +import frappe +from frappe.utils import now_datetime + +def poll_leads(): + """Polling job to fetch leads from Meta Ads API""" + + config = frappe.get_single("Meta Webhook Config") + if not config.enable_polling: + return # Exit if polling is disabled + + job_log = frappe.new_doc("Polling Summary Log") + job_log.job_start_time = now_datetime() + job_log.polling_interval_used = config.polling_interval + + total_leads = 0 + new_leads = 0 + duplicates = 0 + failed = 0 + error_messages = [] + + try: + # Fetch only new leads (after last polling time) + last_poll_time = config.last_polling_time or "1970-01-01 00:00:00" + leads = fetch_leads_from_meta(last_poll_time) + + total_leads = len(leads) + + for lead in leads: + leadgen_id = lead.get("leadgen_id") + + if frappe.db.exists("Meta Webhook Lead Logs", {"leadgen_id": leadgen_id}): + duplicates += 1 + continue # Skip if duplicate + + try: + log_entry = frappe.new_doc("Meta Webhook Lead Logs") + log_entry.update({ + "leadgen_id": leadgen_id, + "raw_payload": json.dumps(lead), + "received_time": now_datetime(), + "source": "Polling", + "polling_summary_reference": job_log.name + }) + log_entry.insert(ignore_permissions=True) + new_leads += 1 + except Exception as e: + failed += 1 + error_messages.append(str(e)) + + job_log.new_leads_created = new_leads + job_log.duplicate_leads = duplicates + job_log.failed_leads = failed + job_log.total_leads_fetched = total_leads + job_log.error_messages = "\n".join(error_messages) + job_log.job_end_time = now_datetime() + + job_log.insert(ignore_permissions=True) + frappe.db.commit() + + # Update last polling time + config.db_set("last_polling_time", now_datetime()) + + except Exception as e: + job_log.error_messages = f"Polling failed: {str(e)}" + job_log.insert(ignore_permissions=True) + frappe.db.commit() + frappe.logger().error(f"Error in polling job: {str(e)}", exc_info=True) diff --git a/onelead/utils/meta_lead.py b/onelead/utils/meta_lead.py index 811527f..46264e9 100644 --- a/onelead/utils/meta_lead.py +++ b/onelead/utils/meta_lead.py @@ -64,7 +64,7 @@ def leadgen(): # check if developemnt mode is enabled then skip signature verification if not frappe.conf.developer_mode: if not verify_signature(signature, json.dumps(data), app_secret): - frappe.logger().warning("Invalid signature. Payload verification failed.") + frappe.logger().error("Invalid signature. Payload verification failed.") return Response("Invalid signature", status=403) # Log incoming payload @@ -102,8 +102,10 @@ def create_lead_log(data, lead_data, global_conf): "received_time": now(), "leadgen_id": leadgen_id, "page_id": page_id, + "source": "Webhook", "ad_id": ad_id, "form_id": form_id, + "Source": 'Webhook', "created_time": convert_epoch_to_frappe_date(created_time), "processing_status": "Pending" }) @@ -117,10 +119,20 @@ def create_lead_log(data, lead_data, global_conf): form_doc = frappe.get_doc("Meta Lead Form", {"form_id": form_id}) lead_log.lead_doctype = form_doc.lead_doctype_reference lead_log.lead_form = form_id + # 1a. remove form_doc.campaign for M:M relationship + # if form_doc.campaign: + # lead_log.campaign = form_doc.campaign + # 1a. add form_doc.ads for M:M relationship + # if form_doc.ads: + # lead_log.ad = form_doc.ads if not lead_log.lead_doctype: lead_log.processing_status = "Unconfigured" - lead_log.error_message = "No lead_doctype_reference found in 'Meta Lead Form'" - + lead_log.error_message = "No `Lead Doctype Reference` found in 'Meta Lead Form'" + else: + lead_log.processing_status = "Unconfigured" + lead_log.error_message = f"No form found in `Meta Lead Form` for form_id: {lead_log.form_id}, please fetch forms again to get the latest forms." + return + if config: lead_log.config_reference = config.name if not config.get('enable'): @@ -129,9 +141,7 @@ def create_lead_log(data, lead_data, global_conf): lead_log.campaign = config.campaign else: lead_log.processing_status = "Unconfigured" - lead_log.error_message = ("No configuration found for form_id in 'Meta Lead Form'" - if not configured_form else - "No configuration found for page_id and form_id in 'Meta Ads Webhook Config'") + lead_log.error_message = ("No configuration found for form_id in 'Meta Lead Form'" if not configured_form else "No configuration found for page_id and form_id in 'Meta Ads Webhook Config'") lead_log.insert(ignore_permissions=True) @@ -163,178 +173,3 @@ def convert_epoch_to_frappe_date(epoch_time): # Format the datetime object to Frappe's preferred format frappe_date = dt.strftime('%Y-%m-%d %H:%M:%S') return frappe_date - - -# def process_lead_changes(data, lead_log): -# try: -# if "entry" in data: -# for entry in data["entry"]: -# if "changes" in entry: -# for change in entry["changes"]: -# if change["field"] == "leadgen": -# leadgen_id = change["value"].get("leadgen_id") -# # adgroup_id = change["value"].get("adgroup_id") -# page_id = change["value"].get("page_id") - -# try: -# lead_exists = frappe.get_doc("Meta Lead Logs", f"{page_id}_{leadgen_id}") - -# if lead_exists: -# frappe.logger().info(f"Lead with leadgen_id {leadgen_id} exists!") -# return -# except: -# # New Lead entry, no lead found. -# pass - -# lead_log.set("leadgen_id", leadgen_id) -# lead_log.set("page_id", page_id) - -# lead_conf = None - -# # Only during testing there won't be any page_id. when hit from developer plateform webhook. -# if page_id: -# filters = {} -# # if adgroup_id: -# # filters['ad_group_id'] = adgroup_id -# if page_id: -# filters['page_id'] = page_id - -# lead_conf = frappe.get_all('Meta Ad Campaign Config', filters=filters, limit_page_length=1) - -# if lead_conf and len(lead_conf) > 0 and leadgen_id: - -# # Call and fetch doc data again, to fetch all child table data as well. -# config = frappe.get_doc('Meta Ad Campaign Config', lead_conf[0]['name']) -# lead_log.set("meta_ads_config", config.name) -# lead_log.set("lead_doctype_reference", config.lead_doctype) - -# lead_log.insert(ignore_permissions=True) - -# frappe.logger().info(f"Lead configuration found for unique key: { page_id}") -# fetch_lead_data(leadgen_id, config, lead_log) -# else: -# frappe.logger().error(f"No lead configuration found for unique key: { page_id}") - -# except Exception as e: -# frappe.logger().error(f"Error in processing lead changes: {str(e)}", exc_info=True) -# raise - -# def fetch_lead_data(leadgen_id, lead_conf, lead_log): -# try: -# conf = frappe.get_doc("Meta Webhook Config") -# url = f"{conf.meta_url}/{conf.meta_api_version}/{leadgen_id}/" - -# user_access_token = get_decrypted_password('Meta Ad Campaign Config', lead_conf.name, 'user_access_token') -# # access_token = get_decrypted_password("Meta Webhook Config", conf.name, "access_token") -# params = {"access_token": user_access_token} -# frappe.logger().info(f"Fetching lead data from Meta API for leadgen_id: {leadgen_id}") -# response = requests.get(url, params=params) - -# if response.status_code == 200: -# frappe.logger().info(f"Successfully fetched lead data for leadgen_id: {leadgen_id}") -# lead_data = response.json() -# lead_log.set("lead_json", lead_data) -# lead_log.save(ignore_permissions=True) - -# new_lead = process_lead_data(lead_data, lead_conf) -# print(new_lead) -# # lead_log.set("lead_doctype", new_lead.get("lead_name")) -# lead_log.set("lead_entry_successful", True) -# lead_log.save(ignore_permissions=True) - -# return new_lead -# else: -# frappe.logger().error(f"Failed to fetch lead data for leadgen_id: {leadgen_id}. Status Code: {response.status_code}, Response: {response.text}") - -# except requests.RequestException as e: -# lead_log.set("error", f"Request error while fetching lead data: {str(e)}") -# lead_log.save(ignore_permissions=True) -# frappe.logger().error(f"Request error while fetching lead data: {str(e)}", exc_info=True) -# raise - -# except Exception as e: -# lead_log.set("error", f"Error in fetching lead data: {str(e)}") -# lead_log.save(ignore_permissions=True) -# frappe.logger().error(f"Error in fetching lead data: {str(e)}", exc_info=True) -# raise - -# def process_lead_data(lead_data, lead_conf): -# try: -# field_data = lead_data.get("field_data", []) -# lead_doctype = lead_conf.get('lead_doctype') -# new_lead = frappe.new_doc(lead_doctype) -# wb_lead_info = {field["name"]: field["values"][0] for field in field_data} - -# frappe.logger().info(f"Processing lead data: {wb_lead_info}") - -# for mapping in lead_conf.mapping: -# ad_form_key = mapping.ad_form_field_key -# lead_doc_field = mapping.lead_doctype_field - -# if ad_form_key in wb_lead_info: -# ad_form_value = wb_lead_info.get(ad_form_key) -# if ad_form_key == "phone_number": -# ad_form_value = formate_phone_number(wb_lead_info.get(ad_form_key)) -# new_lead.set(lead_doc_field, ad_form_value) - -# for constant in lead_conf.constants: -# new_lead.set(constant.lead_doctype_field, constant.constant_value) - -# # if lead_conf.time_field: -# # new_lead.set(lead_conf.lead_doctype_time_field, format_epoch_time()) - -# new_lead.insert(ignore_permissions=True) -# frappe.db.commit() - -# frappe.logger().info(f"Lead created successfully with name: {new_lead.name}") -# return {"message": "Lead created successfully", "lead_name": new_lead.name} - -# except Exception as e: -# frappe.logger().error(f"Error in processing lead data: {str(e)}", exc_info=True) -# raise - -# def formate_phone_number(phone_number): -# if phone_number and phone_number.startswith("+"): -# phone_number = phone_number.replace(" ", "").replace("-", "") -# return f"{phone_number[:3]}-{phone_number[3:]}" -# else: -# return phone_number - -# def format_epoch_time(epoch_time): - - -# def process_lead_data(lead_data, lead_conf): -# field_data = lead_data.get("field_data", []) -# new_lead = frappe.new_doc(lead_conf.get('lead_doctype')) -# wb_lead_info = {field["name"]: field["values"][0] for field in field_data} -# print(wb_lead_info) - -# for mapping in lead_conf.mapping: -# ad_form_key = mapping.ad_form_field_key -# lead_doc_field = mapping.lead_doctype_field - -# if ad_form_key in wb_lead_info: -# new_lead.set(lead_doc_field, wb_lead_info.get(ad_form_key)) - -# for constant in lead_conf.constants: -# new_lead.set(constant.lead_doctype_field, constant.constant_value) - -# new_lead.insert(ignore_permissions=True) -# frappe.db.commit() -# # # Format phone number if it contains a country code -# # phone_number = wb_lead_info.get("phone_number") -# # if phone_number and phone_number.startswith("+"): -# # phone_number = phone_number.replace(" ", "").replace("-", "") -# # formatted_phone_number = f"{phone_number[:3]}-{phone_number[3:]}" -# # else: -# # formatted_phone_number = phone_number - -# # data = { -# # "doctype": "Leads", -# # "lead_name": wb_lead_info.get("full_name"), -# # "email_id": wb_lead_info.get("email"), -# # "mobile_no": formatted_phone_number -# # } -# # frappe.get_doc(data).insert(ignore_permissions=True) -# frappe.logger().info("Lead data from google ads: {}".format(new_lead)) -# return {"message": "Lead created successfully", "lead_name": new_lead.name} \ No newline at end of file