diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7375ca1 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,74 @@ +exclude: 'node_modules|.git' +default_stages: [commit] +fail_fast: false + + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.3.0 + hooks: + - id: trailing-whitespace + files: "erpnext.*" + exclude: ".*json$|.*txt$|.*csv|.*md" + - id: check-yaml + - id: no-commit-to-branch + args: ['--branch', 'develop'] + - id: check-merge-conflict + - id: check-ast + - id: check-json + - id: check-toml + - id: check-yaml + - id: debug-statements + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v2.7.1 + hooks: + - id: prettier + types_or: [javascript, vue, scss] + # Ignore any files that might contain jinja / bundles + exclude: | + (?x)^( + erpnext/public/dist/.*| + cypress/.*| + .*node_modules.*| + .*boilerplate.*| + erpnext/public/js/controllers/.*| + erpnext/templates/pages/order.js| + erpnext/templates/includes/.* + )$ + + - repo: https://github.com/pre-commit/mirrors-eslint + rev: v8.44.0 + hooks: + - id: eslint + types_or: [javascript] + args: ['--quiet'] + # Ignore any files that might contain jinja / bundles + exclude: | + (?x)^( + erpnext/public/dist/.*| + cypress/.*| + .*node_modules.*| + .*boilerplate.*| + erpnext/public/js/controllers/.*| + erpnext/templates/pages/order.js| + erpnext/templates/includes/.* + )$ + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.2.0 + hooks: + - id: ruff + name: "Run ruff import sorter" + args: ["--select=I", "--fix"] + + - id: ruff + name: "Run ruff linter" + + - id: ruff-format + name: "Run ruff formatter" + +ci: + autoupdate_schedule: weekly + skip: [] + submodules: false diff --git a/twilio_integration/twilio_integration/doctype/twilio_settings/twilio_settings.json b/twilio_integration/twilio_integration/doctype/twilio_settings/twilio_settings.json index 049a723..f02ff63 100644 --- a/twilio_integration/twilio_integration/doctype/twilio_settings/twilio_settings.json +++ b/twilio_integration/twilio_integration/doctype/twilio_settings/twilio_settings.json @@ -92,6 +92,7 @@ "fieldname": "whatsapp_no", "fieldtype": "Data", "label": "Number", + "mandatory_depends_on": "eval: doc.enabled", "options": "Phone" }, { @@ -108,7 +109,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-07-08 00:46:42.317641", + "modified": "2025-02-28 13:40:12.974554", "modified_by": "Administrator", "module": "Twilio Integration", "name": "Twilio Settings", diff --git a/twilio_integration/twilio_integration/doctype/twilio_settings/twilio_settings.py b/twilio_integration/twilio_integration/doctype/twilio_settings/twilio_settings.py index a1d6f78..a78f160 100644 --- a/twilio_integration/twilio_integration/doctype/twilio_settings/twilio_settings.py +++ b/twilio_integration/twilio_integration/doctype/twilio_settings/twilio_settings.py @@ -3,91 +3,100 @@ # For license information, please see license.txt from __future__ import unicode_literals + import frappe -from frappe.model.document import Document from frappe import _ -from frappe.utils.password import get_decrypted_password +from frappe.model.document import Document +from twilio.rest import Client -from six import string_types -import re -from json import loads, dumps -from random import randrange +from ...utils import get_public_url, validate_phone_number -from twilio.rest import Client -from ...utils import get_public_url class TwilioSettings(Document): - friendly_resource_name = "ERPNext" # System creates TwiML app & API keys with this name. - - def validate(self): - self.validate_twilio_account() - - def on_update(self): - # Single doctype records are created in DB at time of installation and those field values are set as null. - # This condition make sure that we handle null. - if not self.account_sid: - return - - twilio = Client(self.account_sid, self.get_password("auth_token")) - self.set_api_credentials(twilio) - self.set_application_credentials(twilio) - self.reload() - - def validate_twilio_account(self): - try: - twilio = Client(self.account_sid, self.get_password("auth_token")) - twilio.api.accounts(self.account_sid).fetch() - return twilio - except Exception: - frappe.throw(_("Invalid Account SID or Auth Token.")) - - def set_api_credentials(self, twilio): - """Generate Twilio API credentials if not exist and update them. - """ - if self.api_key and self.api_secret: - return - new_key = self.create_api_key(twilio) - self.api_key = new_key.sid - self.api_secret = new_key.secret - frappe.db.set_value('Twilio Settings', 'Twilio Settings', { - 'api_key': self.api_key, - 'api_secret': self.api_secret - }) - - def set_application_credentials(self, twilio): - """Generate TwiML app credentials if not exist and update them. - """ - credentials = self.get_application(twilio) or self.create_application(twilio) - self.twiml_sid = credentials.sid - frappe.db.set_value('Twilio Settings', 'Twilio Settings', 'twiml_sid', self.twiml_sid) - - def create_api_key(self, twilio): - """Create API keys in twilio account. - """ - try: - return twilio.new_keys.create(friendly_name=self.friendly_resource_name) - except Exception: - frappe.log_error(title=_("Twilio API credential creation error.")) - frappe.throw(_("Twilio API credential creation error.")) - - def get_twilio_voice_url(self): - url_path = "/api/method/twilio_integration.twilio_integration.api.voice" - return get_public_url(url_path) - - def get_application(self, twilio, friendly_name=None): - """Get TwiML App from twilio account if exists. - """ - friendly_name = friendly_name or self.friendly_resource_name - applications = twilio.applications.list(friendly_name) - return applications and applications[0] - - def create_application(self, twilio, friendly_name=None): - """Create TwilML App in twilio account. - """ - friendly_name = friendly_name or self.friendly_resource_name - application = twilio.applications.create( - voice_method='POST', - voice_url=self.get_twilio_voice_url(), - friendly_name=friendly_name - ) - return application \ No newline at end of file + friendly_resource_name = ( + "ERPNext" # System creates TwiML app & API keys with this name. + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._twilio_client = None + + def validate(self): + if not self.enabled: + return + self.validate_whatsapp_number() + self.validate_twilio_account() + + def on_update(self): + # Single doctype records are created in DB at time of installation and those field values are set as null. + # This condition make sure that we handle null. + self.update_twilio_account() + + def validate_whatsapp_number(self): + validate_phone_number(self.whatsapp_no) + + def update_twilio_account(self): + twilio = self.get_twilio_client() + + if not (self.api_key and self.api_secret): + self.set_api_credentials(twilio) + + self.set_application_credentials(twilio) + self.reload() + + def get_twilio_client(self): + if not self._twilio_client: + self._twilio_client = Client( + self.account_sid, self.get_password("auth_token") + ) + return self._twilio_client + + def validate_twilio_account(self): + try: + twilio = self.get_twilio_client() + twilio.api.accounts(self.account_sid).fetch() + except Exception: + frappe.throw(_("Invalid Account SID or Auth Token.")) + + def set_api_credentials(self, twilio): + """Generate Twilio API credentials if not exist and update them.""" + new_key = self.create_api_key(twilio) + self.api_key = new_key.sid + self.api_secret = new_key.secret + frappe.db.set_single_value( + "Twilio Settings", {"api_key": self.api_key, "api_secret": self.api_secret} + ) + + def set_application_credentials(self, twilio): + """Generate TwiML app credentials if not exist and update them.""" + credentials = self.get_application(twilio) or self.create_application(twilio) + self.twiml_sid = credentials.sid + frappe.db.set_single_value("Twilio Settings", "twiml_sid", self.twiml_sid) + + def create_api_key(self, twilio): + """Create API keys in twilio account.""" + try: + return twilio.new_keys.create(friendly_name=self.friendly_resource_name) + except Exception: + frappe.log_error(title=_("Twilio API credential creation error.")) + frappe.throw(_("Twilio API credential creation error.")) + + def get_twilio_voice_url(self): + url_path = "/api/method/twilio_integration.twilio_integration.api.voice" + return get_public_url(url_path) + + def get_application(self, twilio, friendly_name=None): + """Get TwiML App from twilio account if exists.""" + friendly_name = friendly_name or self.friendly_resource_name + applications = twilio.applications.list(friendly_name) + return applications and applications[0] + + def create_application(self, twilio, friendly_name=None): + """Create TwilML App in twilio account.""" + friendly_name = friendly_name or self.friendly_resource_name + application = twilio.applications.create( + voice_method="POST", + voice_url=self.get_twilio_voice_url(), + friendly_name=friendly_name, + ) + return application diff --git a/twilio_integration/twilio_integration/doctype/whatsapp_campaign/whatsapp_campaign.js b/twilio_integration/twilio_integration/doctype/whatsapp_campaign/whatsapp_campaign.js index c62c51e..9f930a9 100644 --- a/twilio_integration/twilio_integration/doctype/whatsapp_campaign/whatsapp_campaign.js +++ b/twilio_integration/twilio_integration/doctype/whatsapp_campaign/whatsapp_campaign.js @@ -2,26 +2,21 @@ // For license information, please see license.txt frappe.ui.form.on('WhatsApp Campaign', { - setup: function(frm) { + onload: function(frm) { frappe.call({ doc: frm.doc, method: 'get_doctype_list', callback: function(r) { if(r.message) { - let options = [] - r.message.forEach((dt) => { - options.push({ - 'label': dt, - 'value': dt - }); - }) - frappe.meta.get_docfield('WhatsApp Campaign Recipient', 'campaign_for', frm.doc.name).options = [""].concat(options); + frappe.meta.get_docfield('WhatsApp Campaign Recipient', 'campaign_for', frm.doc.name).options = [""].concat(r.message); } + frm.fields_dict.recipients.grid.add_new_row() } }); }, refresh: function(frm) { + frm.set_df_property("recipients", "reqd", 1); if(frm.doc.status == 'Completed') { frm.disable_form(); frm.disable_save(); diff --git a/twilio_integration/twilio_integration/doctype/whatsapp_campaign/whatsapp_campaign.json b/twilio_integration/twilio_integration/doctype/whatsapp_campaign/whatsapp_campaign.json index f520dc3..e85e41e 100644 --- a/twilio_integration/twilio_integration/doctype/whatsapp_campaign/whatsapp_campaign.json +++ b/twilio_integration/twilio_integration/doctype/whatsapp_campaign/whatsapp_campaign.json @@ -60,7 +60,8 @@ { "fieldname": "total_participants", "fieldtype": "Int", - "label": "Total Participants" + "label": "Total Participants", + "read_only": 1 }, { "fieldname": "campaign", @@ -80,8 +81,7 @@ "fieldname": "recipients", "fieldtype": "Table", "label": "Recipients", - "options": "WhatsApp Campaign Recipient", - "reqd": 1 + "options": "WhatsApp Campaign Recipient" }, { "fieldname": "messge_section", @@ -111,7 +111,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-07-14 11:40:59.599233", + "modified": "2025-02-28 16:22:54.783961", "modified_by": "Administrator", "module": "Twilio Integration", "name": "WhatsApp Campaign", diff --git a/twilio_integration/twilio_integration/doctype/whatsapp_campaign/whatsapp_campaign.py b/twilio_integration/twilio_integration/doctype/whatsapp_campaign/whatsapp_campaign.py index 9e4a929..3a8eb36 100644 --- a/twilio_integration/twilio_integration/doctype/whatsapp_campaign/whatsapp_campaign.py +++ b/twilio_integration/twilio_integration/doctype/whatsapp_campaign/whatsapp_campaign.py @@ -2,92 +2,118 @@ # For license information, please see license.txt import frappe +from frappe import _ from frappe.model.document import Document from frappe.utils import get_site_url -from twilio_integration.twilio_integration.doctype.whatsapp_message.whatsapp_message import WhatsAppMessage - -supported_file_ext = ['jpg', - 'jpeg', - 'png', - 'mp3', - 'ogg', - 'amr', - 'pdf', - 'mp4' -] -class WhatsAppCampaign(Document): - def validate(self): - if self.scheduled_time and self.status != 'Completed': - current_time = frappe.utils.now_datetime() - scheduled_time = frappe.utils.get_datetime(self.scheduled_time) - - if scheduled_time < current_time: - frappe.throw(_("Scheduled Time must be a future time.")) - - self.status = 'Scheduled' - - self.all_missing_recipients() - - def validate_attachment(self): - attachment = self.get_attachment() - if attachment: - if attachment.file_size > 16777216: - frappe.throw(_('Attachment size must be less than 16MB.')) - - if attachment.is_private: - frappe.throw(_('Attachment must be public.')) - - if attachment.get_extension() not in supported_file_ext: - frappe.throw(_('Attachment format not supported.')) - - def get_attachment(self): - file = frappe.db.get_value("File", {"attached_to_name": self.doctype, "attached_to_doctype": self.name, "is_private":0}, 'name') - - if file: - return frappe.get_doc('File', file) - return None - - def get_whatsapp_contact(self): - contacts = [recipient.whatsapp_no for recipient in self.recipients if recipient.whatsapp_no] - - return contacts - - def all_missing_recipients(self): - for recipient in self.recipients: - if not recipient.whatsapp_no: - recipient.whatsapp_no = frappe.db.get_value(recipient.campaign_for, recipient.recipient, 'whatsapp_no') - - self.total_participants = len(self.recipients) - - @frappe.whitelist() - def get_doctype_list(self): - standard_doctype = frappe.db.sql_list("""SELECT dt.parent FROM `tabDocField` - df INNER JOIN `tabDoctype` dt ON dt.name = dt.parent - WHERE df.fieldname='whatsapp_no' AND dt.istable = 0 AND dt.issingle = 0 AND dt.is_tree = 0""") - - custom_doctype = frappe.db.sql_list("""SELECT dt FROM `tabCustom Field` - cf INNER JOIN `tabDoctype` dt ON dt.name = cf.dt - WHERE cf.fieldname='whatsapp_no' AND dt.istable = 0 AND dt.issingle = 0 AND dt.is_tree = 0""") - - return standard_doctype + custom_doctype - - @frappe.whitelist() - def send_now(self): - self.validate_attachment() - media = self.get_attachment() - self.db_set('status', 'In Progress') - if media: - media = get_site_url(frappe.local.site) + media.file_url - - WhatsAppMessage.send_whatsapp_message( - receiver_list = self.get_whatsapp_contact(), - message = self.message, - doctype = self.doctype, - docname = self.name, - media = media - ) - - self.db_set('status', 'Completed') +from twilio_integration.twilio_integration.doctype.whatsapp_message.whatsapp_message import ( + WhatsAppMessage, +) + +from ...utils import validate_phone_number +supported_file_ext = ["jpg", "jpeg", "png", "mp3", "ogg", "amr", "pdf", "mp4"] + +class WhatsAppCampaign(Document): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.contacts = [] + + def validate(self): + self.validate_scheduled_time() + self.fetch_and_validate_recipients() + self.set_total_participants() + + def validate_scheduled_time(self): + if self.scheduled_time and self.status != "Completed": + current_time = frappe.utils.now_datetime() + scheduled_time = frappe.utils.get_datetime(self.scheduled_time) + + if scheduled_time < current_time: + frappe.throw(_("Scheduled Time must be a future time.")) + + self.status = "Scheduled" + + def validate_attachment(self): + attachment = self.get_attachment() + if attachment: + if attachment.file_size > 16777216: + frappe.throw(_("Attachment size must be less than 16MB.")) + + if attachment.get_extension() not in supported_file_ext: + frappe.throw(_("Attachment format not supported.")) + + def set_total_participants(self): + self.total_participants = len(self.recipients) + + def get_attachment(self): + file = frappe.db.get_value( + "File", + { + "attached_to_name": self.doctype, + "attached_to_doctype": self.name, + "is_private": 0, + }, + "name", + ) + + if file: + return frappe.get_doc("File", file) + return None + + def get_whatsapp_contact(self): + contacts = self.contacts + return contacts + + def fetch_and_validate_recipients(self): + for recipient in self.recipients: + if not (recipient.campaign_for and recipient.recipient): + frappe.throw( + _("{0} is missing recipient or campaign for.").format( + recipient.name + ) + ) + if not recipient.whatsapp_no: + recipient.whatsapp_no = frappe.db.get_value( + recipient.campaign_for, recipient.recipient, "whatsapp_no" + ) + validate_phone_number(recipient.whatsapp_no) + self.contacts.append(recipient.whatsapp_no) + + @frappe.whitelist() + def get_doctype_list(self): + standard_doctype = frappe.db.sql_list( + """SELECT df.parent FROM `tabDocField` + df INNER JOIN `tabDocType` dt ON dt.name = df.parent + WHERE df.fieldname='whatsapp_no' AND dt.istable = 0 AND dt.issingle = 0 AND dt.is_tree = 0""" + ) + + custom_doctype = frappe.db.sql_list( + """SELECT cf.dt FROM `tabCustom Field` + cf INNER JOIN `tabDocType` dt ON dt.name = cf.dt + WHERE cf.fieldname='whatsapp_no' AND dt.istable = 0 AND dt.issingle = 0 AND dt.is_tree = 0""" + ) + + doctype = [ + {"label": dt, "value": dt} for dt in (standard_doctype + custom_doctype) + ] + return doctype + + @frappe.whitelist() + def send_now(self): + self.validate_attachment() + self.fetch_and_validate_recipients() + media = self.get_attachment() + self.db_set("status", "In Progress") + if media: + media = get_site_url(frappe.local.site) + media.file_url + WhatsAppMessage.send_whatsapp_message( + receiver_list=self.get_whatsapp_contact(), + message=self.message, + doctype=self.doctype, + docname=self.name, + media=media, + ) + + self.db_set("status", "Completed") diff --git a/twilio_integration/twilio_integration/doctype/whatsapp_message/whatsapp_message.py b/twilio_integration/twilio_integration/doctype/whatsapp_message/whatsapp_message.py index c2eb98e..6a41a56 100644 --- a/twilio_integration/twilio_integration/doctype/whatsapp_message/whatsapp_message.py +++ b/twilio_integration/twilio_integration/doctype/whatsapp_message/whatsapp_message.py @@ -2,6 +2,7 @@ # For license information, please see license.txt import frappe +import json from frappe.model.document import Document from six import string_types from frappe.utils.password import get_decrypted_password @@ -25,7 +26,7 @@ def send(self): except Exception as e: self.db_set('status', "Error") - frappe.log_error(e, title = _('Twilio WhatsApp Message Error')) + frappe.log_error(message = _(e), title = _('Twilio WhatsApp Message Error')) def get_message_dict(self): args = { @@ -42,7 +43,7 @@ def get_message_dict(self): @classmethod def send_whatsapp_message(self, receiver_list, message, doctype, docname, media=None): if isinstance(receiver_list, string_types): - receiver_list = loads(receiver_list) + receiver_list = json.loads(receiver_list) if not isinstance(receiver_list, list): receiver_list = [receiver_list] diff --git a/twilio_integration/twilio_integration/utils.py b/twilio_integration/twilio_integration/utils.py index 2cb665e..fa14b75 100644 --- a/twilio_integration/twilio_integration/utils.py +++ b/twilio_integration/twilio_integration/utils.py @@ -1,5 +1,7 @@ -from pyngrok import ngrok import frappe +import re +from pyngrok import ngrok +from frappe import _ from frappe.utils import get_url @@ -26,3 +28,7 @@ def merge_dicts(d1: dict, d2: dict): ... {'name1': {'age': 20, 'phone': '+xxx'}, 'name2': {'age': 30, 'phone': '+yyy'}} """ return {k:{**v, **d2.get(k, {})} for k, v in d1.items()} + +def validate_phone_number(phone_number : str) -> None: + if not re.match(r"^\+(?![\s0])[\d\s]+\d$", phone_number): + frappe.throw(_("Pickup contact phone must consist of a '+' followed by one or more digits."))